feat: backtest gap handling task and operator updates
This commit is contained in:
parent
8d4cb785e8
commit
5f7b972c44
20 changed files with 1681 additions and 26 deletions
|
|
@ -37,16 +37,17 @@
|
|||
|
||||
사용자가 지정한 종목군을 수집 대상으로 삼고, 매일 반복 가능한 수집 단위로 관리한다.
|
||||
|
||||
- [ ] [watchlist-config] 선택 종목군을 수집 대상으로 등록하고 갱신할 수 있는 headless 설정 경계를 만든다.
|
||||
- [x] [watchlist-config] 선택 종목군을 수집 대상으로 등록하고 갱신할 수 있는 headless 설정 경계를 만든다.
|
||||
- [x] [daily-collect] 선택 종목군에 대해 일별 수집 job을 실행하고 재실행할 수 있는 worker/CLI 경계를 만든다.
|
||||
- [ ] [freshness-status] 수집 성공, 실패, 누락, 최신 일자를 operator가 확인할 수 있는 상태 출력을 만든다.
|
||||
- [x] [freshness-status] 수집 성공, 실패, 누락, 최신 일자를 operator가 확인할 수 있는 상태 출력을 만든다.
|
||||
|
||||
### Epic: [dataset] Backtest-ready dataset
|
||||
|
||||
수집한 원천 데이터를 백테스트 입력으로 사용할 수 있는 정제 데이터로 저장한다.
|
||||
작업 운영 기준: 문서, fixture, test, 단일 모듈 정합성 보강처럼 작고 되돌리기 쉬운 작업은 바로 작업한다. DB schema, persistence contract, provider adapter, API/worker 경계처럼 여러 모듈과 데이터 계약을 바꾸는 큰 작업은 먼저 plan을 작성한다.
|
||||
|
||||
- [ ] [provider-boundary] KIS/KRX/DART/US provider 등 확장 가능한 data source adapter 경계를 정리한다.
|
||||
- [ ] [normalized-store] 백테스트에 필요한 정제 필드만 DB에 저장하고 원천 payload 전체 저장은 기본값에서 제외한다.
|
||||
- [x] [provider-boundary] KIS/KRX/DART/US provider 등 확장 가능한 data source adapter 경계를 정리한다.
|
||||
- [x] [normalized-store] 백테스트에 필요한 정제 필드만 DB에 저장하고 원천 payload 전체 저장은 기본값에서 제외한다.
|
||||
- [ ] [gap-handling] 결측, 휴장일, 중복, provider 지연을 백테스트 입력에서 식별할 수 있게 한다.
|
||||
|
||||
### Epic: [backtest-ready] Repeatable execution input
|
||||
|
|
@ -60,7 +61,7 @@
|
|||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 일부 기능 Task만 완료되었으며 전체 마일스톤 완료 조건은 아직 충족되지 않았다.
|
||||
- 완료 근거: `watchlist-config`, `daily-collect`, `freshness-status`, `provider-boundary`, `normalized-store` 기능 Task가 완료되었으나 전체 마일스톤 완료 조건은 아직 충족되지 않았다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
|
|
@ -80,7 +81,11 @@
|
|||
- 표준선(선택): 백테스트 인프라는 먼저 화면 없이 CLI, terminal output, YAML scenario, log, test fixture로 검증한다.
|
||||
- 표준선(선택): 데이터 접근 범위는 좁히지 않고 확장 가능한 adapter 경계로 열어두되, DB에는 백테스트와 운영 판단에 필요한 정제 데이터만 남긴다.
|
||||
- 표준선(선택): agent는 이 마일스톤의 구현 수단이나 주식 분석 코어가 아니다.
|
||||
- 완료 근거(watchlist-config): CLI scenario schema에 top-level named universe 설정과 `request.universe` 참조가 추가되었고, KR/US/smoke market data fixtures가 inline watchlist 대신 named universe를 사용한다. 2026-06-14에 `go test ./apps/cli/internal/operator ./apps/cli/internal/cli`가 통과했다. 증거: `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/01_watchlist_config/complete.log`.
|
||||
- 완료 근거(daily-collect): `import_daily_bars`가 API -> worker -> importer로 전달되고 CLI scenario에서 실행/재실행 가능한 경계가 이미 구현되어 있으며, 2026-06-14에 `go test ./packages/domain/... ./services/worker/internal/jobs ./services/worker/internal/marketdata/importer ./services/worker/internal/socket ./services/api/internal/socket ./services/api/internal/workerclient ./apps/cli/internal/operator`가 통과했다.
|
||||
- 완료 근거(freshness-status): `collection_freshness` operator scenario가 freshness status, missing count, latest date를 text/JSONL 출력과 handoff matrix에 포함하며, 2026-06-14에 `go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli`가 통과했다. 증거: `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/complete.log`.
|
||||
- 완료 근거(provider-boundary): provider vocabulary에 KIS/KRX/DART/Yahoo data source id를 고정하고 `ProviderCapability`가 provider, venue, market, daily-bar timeframe 경계를 검증하도록 보강했다. 2026-06-14에 `go test -count=1 ./packages/domain/...`와 `go test -count=1 ./services/worker/internal/jobs`가 통과했다.
|
||||
- 완료 근거(normalized-store): PostgreSQL bars schema와 SQL query contract가 normalized OHLCV 필드만 사용하고 raw provider payload column을 포함하지 않도록 guard test를 추가했다. 2026-06-14에 `go test -count=1 ./services/worker/internal/storage/postgres`가 통과했다.
|
||||
- 선행 작업: Backtest Engine Baseline, Backtest Analysis Surface
|
||||
- 후속 작업: Backtest Scenario Automation, Data Quality Monitoring, Agent Tooling Bridge 후보
|
||||
- 확인 필요: 없음
|
||||
|
|
|
|||
|
|
@ -0,0 +1,286 @@
|
|||
<!-- task=m-backtest-data-collection-infrastructure/03+02_gap_handling plan=0 tag=DATASET_GAP -->
|
||||
|
||||
# Code Review Reference - DATASET_GAP
|
||||
|
||||
> **[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/03+02_gap_handling, plan=0, tag=DATASET_GAP
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
|
||||
- Task ids:
|
||||
- `gap-handling`: 결측, 휴장일, 중복, provider 지연을 백테스트 입력에서 식별할 수 있게 한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G06.md` -> `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` -> `plan_cloud_G06_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/03+02_gap_handling/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [DATASET_GAP-1] Scenario Gap Contract | [x] |
|
||||
| [DATASET_GAP-2] Runner Gap Computation | [x] |
|
||||
| [DATASET_GAP-3] Output Fixtures And Handoff | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] `collection_freshness` scenario schema와 validation에 기대 거래일 및 gap expectation 필드를 추가한다.
|
||||
- [x] `runCollectionFreshness`에서 결측 거래일, 중복 일자, provider 지연을 계산하고 expectation mismatch를 검증한다.
|
||||
- [x] `StepEvent` JSONL/text 출력과 status fixtures, handoff matrix, handoff tests를 새 key 계약에 맞춘다.
|
||||
- [x] `go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli`를 실행하고 통과 출력을 기록한다.
|
||||
- [x] `rg --sort path -n "gap_status|gap_count|gap_yyyymmdd|duplicate_count|duplicate_yyyymmdd|provider_delay_days|expected_yyyymmdd" apps/cli/internal/operator apps/cli/testdata/operator`를 실행해 새 계약 위치를 확인한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-backtest-data-collection-infrastructure/03+02_gap_handling/`를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/03+02_gap_handling/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-backtest-data-collection-infrastructure/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- `expected_yyyymmdd` 날짜들을 순회하며 gap을 검사할 때, 꼬리 잘림 현상(delay)에 의해 결측이 잡혀서 gap과 delay가 동시에 참이 되어 무조건 `mixed` 상태가 되는 것을 방지하기 위해, `latest` 이후의 날짜(`d > latest`)는 gap 검사 대상에서 제외하고 `delay`로만 측정하도록 필터링을 도입했다. 이로 인해 꼬리만 잘린 경우 `delayed` 단독 상태 판별이 가능해졌다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- `GapStatus`가 scenarios 상에 명시되어 계산된 경우(즉 freshness query 등에서 gap 검사를 수행한 경우)에만 `gap_status`, `gap_count`, `gap_yyyymmdd`, `duplicate_count`, `duplicate_yyyymmdd`, `provider_delay_days`가 JSONL 및 Text에 직렬화되어 출력되도록 하여, gap 검사를 수행하지 않는 일반 freshness query 등과의 역호환성을 완벽히 보장하도록 설계하였다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `expected_yyyymmdd` validation이 malformed, unsorted, duplicate, out-of-window 입력을 막는지 확인한다.
|
||||
- `gap_status` 우선순위와 expectation mismatch 메시지가 테스트와 실제 output에서 일치하는지 확인한다.
|
||||
- JSONL/text output, expected fixtures, handoff matrix, handoff tests가 같은 key 계약을 약속하는지 확인한다.
|
||||
- Roadmap Targets가 `gap-handling` 하나만 완료 대상으로 남아 있는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### DATASET_GAP-1 중간 검증
|
||||
```bash
|
||||
$ go test -count=1 ./apps/cli/internal/operator -run 'TestValidate.*(Gap|ExpectedYYYYMMDD|Freshness)'
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.005s
|
||||
```
|
||||
|
||||
### DATASET_GAP-2 중간 검증
|
||||
```bash
|
||||
$ go test -count=1 ./apps/cli/internal/operator -run 'TestRunCollectionFreshness'
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.011s
|
||||
```
|
||||
|
||||
### DATASET_GAP-3 중간 검증
|
||||
```bash
|
||||
$ go test -count=1 ./apps/cli/internal/operator
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.209s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.189s
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/cli 0.007s
|
||||
```
|
||||
|
||||
```bash
|
||||
$ rg --sort path -n "gap_status|gap_count|gap_yyyymmdd|duplicate_count|duplicate_yyyymmdd|provider_delay_days|expected_yyyymmdd" apps/cli/internal/operator apps/cli/testdata/operator
|
||||
apps/cli/internal/operator/output.go:55: GapStatus string
|
||||
apps/cli/internal/operator/output.go:56: GapCount int
|
||||
apps/cli/internal/operator/output.go:57: GapYYYYMMDD []string
|
||||
apps/cli/internal/operator/output.go:58: DuplicateCount int
|
||||
apps/cli/internal/operator/output.go:59: DuplicateYYYYMMDD []string
|
||||
apps/cli/internal/operator/output.go:60: ProviderDelayDays int
|
||||
apps/cli/internal/operator/output.go:204: if ev.GapStatus != "" {
|
||||
apps/cli/internal/operator/output.go:205: fields["gap_status"] = ev.GapStatus
|
||||
apps/cli/internal/operator/output.go:206: if ev.GapCount >= 0 {
|
||||
apps/cli/internal/operator/output.go:207: fields["gap_count"] = ev.GapCount
|
||||
apps/cli/internal/operator/output.go:209: if len(ev.GapYYYYMMDD) > 0 {
|
||||
apps/cli/internal/operator/output.go:210: fields["gap_yyyymmdd"] = ev.GapYYYYMMDD
|
||||
apps/cli/internal/operator/output.go:212: if ev.DuplicateCount >= 0 {
|
||||
apps/cli/internal/operator/output.go:213: fields["duplicate_count"] = ev.DuplicateCount
|
||||
apps/cli/internal/operator/output.go:215: if len(ev.DuplicateYYYYMMDD) > 0 {
|
||||
apps/cli/internal/operator/output.go:216: fields["duplicate_yyyymmdd"] = ev.DuplicateYYYYMMDD
|
||||
...
|
||||
371: line += fmt.Sprintf(" gap_count=%d", ev.GapCount)
|
||||
374: line += fmt.Sprintf(" gap_yyyymmdd=%s", strings.Join(ev.GapYYYYMMDD, ","))
|
||||
377: line += fmt.Sprintf(" duplicate_count=%d", ev.DuplicateCount)
|
||||
380: line += fmt.Sprintf(" duplicate_yyyymmdd=%s", strings.Join(ev.DuplicateYYYYMMDD, ","))
|
||||
383: line += fmt.Sprintf(" provider_delay_days=%d", ev.ProviderDelayDays)
|
||||
|
||||
apps/cli/internal/operator/runner.go
|
||||
1385: ev.ErrorMessage = fmt.Sprintf("expected gap_status %q, got %q", step.Expect.GapStatus, ev.GapStatus)
|
||||
1390: ev.ErrorMessage = fmt.Sprintf("expected gap_count %d, got %d", *step.Expect.GapCount, ev.GapCount)
|
||||
1395: ev.ErrorMessage = fmt.Sprintf("expected duplicate_count %d, got %d", *step.Expect.DuplicateCount, ev.DuplicateCount)
|
||||
1400: ev.ErrorMessage = fmt.Sprintf("expected provider_delay_days %d, got %d", *step.Expect.ProviderDelayDays, ev.ProviderDelayDays)
|
||||
1434: ev.ErrorMessage = fmt.Sprintf("expected gap_status %q, got %q", step.Expect.GapStatus, "error")
|
||||
|
||||
apps/cli/internal/operator/runner_market_test.go
|
||||
924: if !strings.Contains(out, "gap_status="+tc.expectGapStatus) {
|
||||
925: t.Errorf("output %q missing gap_status=%s", out, tc.expectGapStatus)
|
||||
927: if !strings.Contains(out, "gap_count="+itoa(tc.expectGapCount)) {
|
||||
928: t.Errorf("output %q missing gap_count=%d", out, tc.expectGapCount)
|
||||
930: if !strings.Contains(out, "duplicate_count="+itoa(tc.expectDuplicateCount)) {
|
||||
931: t.Errorf("output %q missing duplicate_count=%d", out, tc.expectDuplicateCount)
|
||||
933: if !strings.Contains(out, "provider_delay_days="+itoa(tc.expectDelayDays)) {
|
||||
934: t.Errorf("output %q missing provider_delay_days=%d", out, tc.expectDelayDays)
|
||||
|
||||
apps/cli/internal/operator/scenario.go
|
||||
270: GapStatus string `yaml:"gap_status"`
|
||||
272: GapCount *int `yaml:"gap_count"`
|
||||
274: DuplicateCount *int `yaml:"duplicate_count"`
|
||||
276: ProviderDelayDays *int `yaml:"provider_delay_days"`
|
||||
310: ExpectedYYYYMMDD []string `yaml:"expected_yyyymmdd"`
|
||||
564: return fmt.Errorf("step %q: unsupported expect.gap_status %q (want \"clean\", \"gap\", \"duplicate\", \"delayed\", \"mixed\", or \"error\")", step.ID, step.Expect.GapStatus)
|
||||
567: return fmt.Errorf("step %q: expect.gap_count cannot be negative", step.ID)
|
||||
570: return fmt.Errorf("step %q: expect.duplicate_count cannot be negative", step.ID)
|
||||
573: return fmt.Errorf("step %q: expect.provider_delay_days cannot be negative", step.ID)
|
||||
598: return fmt.Errorf("step %q: expected_yyyymmdd is only supported for timeframe \"daily\"", step.ID)
|
||||
604: return fmt.Errorf("step %q: expected_yyyymmdd[%d] %q must be in YYYYMMDD format", step.ID, idx, d)
|
||||
608: return fmt.Errorf("step %q: expected_yyyymmdd must be in ascending order", step.ID)
|
||||
611: return fmt.Errorf("step %q: expected_yyyymmdd contains duplicate date %q", step.ID, d)
|
||||
617: return fmt.Errorf("step %q: expected_yyyymmdd[%d] %q is out of request window [%d, %d]", step.ID, idx, d, step.Request.FromUnixMs, step.Request.ToUnixMs)
|
||||
|
||||
apps/cli/internal/operator/scenario_test.go
|
||||
985: data := []byte("name: bad_gap_status\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n gap_status: invalid_status\n")
|
||||
988: t.Fatal("expected error for unknown expect.gap_status, got nil")
|
||||
990: if !strings.Contains(err.Error(), "expect.gap_status") {
|
||||
991: t.Errorf("error = %v, want it to mention expect.gap_status", err)
|
||||
1002: name: "negative gap_count",
|
||||
1003: yaml: "name: bad_gap_count\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n gap_count: -1\n",
|
||||
1004: err: "expect.gap_count",
|
||||
1007: name: "negative duplicate_count",
|
||||
1008: yaml: "name: bad_duplicate_count\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n duplicate_count: -1\n",
|
||||
1009: err: "expect.duplicate_count",
|
||||
1012: name: "negative provider_delay_days",
|
||||
1013: yaml: "name: bad_provider_delay\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n provider_delay_days: -1\n",
|
||||
1014: err: "expect.provider_delay_days",
|
||||
1054: expected_yyyymmdd: ["20240527"]
|
||||
1056: expectedErr: "expected_yyyymmdd is only supported for timeframe \"daily\"",
|
||||
1076: expected_yyyymmdd: ["2024-05-27"]
|
||||
1098: expected_yyyymmdd: ["20240528", "20240527"]
|
||||
1120: expected_yyyymmdd: ["20240527", "20240527"]
|
||||
1142: expected_yyyymmdd: ["20240526"]
|
||||
1164: expected_yyyymmdd: ["20240529"]
|
||||
1169: name: "valid expected_yyyymmdd and gap expectations",
|
||||
1186: expected_yyyymmdd: ["20240527", "20240528"]
|
||||
1188: gap_status: clean
|
||||
1189: gap_count: 0
|
||||
1190: duplicate_count: 0
|
||||
1191: provider_delay_days: 0
|
||||
1212: t.Errorf("expected_yyyymmdd len = %d, want 2", len(step.Request.ExpectedYYYYMMDD))
|
||||
1215: t.Errorf("gap_status = %q, want clean", step.Expect.GapStatus)
|
||||
1218: t.Errorf("gap_count = %v, want 0", step.Expect.GapCount)
|
||||
1221: t.Errorf("duplicate_count = %v, want 0", step.Expect.DuplicateCount)
|
||||
1224: t.Errorf("provider_delay_days = %v, want 0", step.Expect.ProviderDelayDays)
|
||||
|
||||
apps/cli/testdata/operator/expected/market_data_status_query.jsonl
|
||||
5:{"action":"collection_freshness","duplicate_count":0,"freshness_status":"fresh","gap_count":0,"gap_status":"clean","latest_yyyymmdd":"20250501","missing_count":0,"provider_delay_days":0,"scenario":"market_data_status_query","status":"ok","step":"freshness_status","type":"step","universe":"kr-core"}
|
||||
|
||||
apps/cli/testdata/operator/expected/us_market_data_status_query.jsonl
|
||||
5:{"action":"collection_freshness","duplicate_count":0,"freshness_status":"fresh","gap_count":0,"gap_status":"clean","latest_yyyymmdd":"20240529","missing_count":0,"provider_delay_days":0,"scenario":"us_market_data_status_query","status":"ok","step":"freshness_status","type":"step","universe":"us-core"}
|
||||
|
||||
apps/cli/testdata/operator/headless_validation.md
|
||||
33:| `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`, `gap_status`, `gap_count`, `duplicate_count`, `provider_delay_days` | `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` | 차트/바 시각화 레이아웃 미정 |
|
||||
34:| `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`, `gap_status`, `gap_count`, `duplicate_count`, `provider_delay_days` | `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` | 차트/바 시각화 레이아웃 미정 |
|
||||
|
||||
apps/cli/testdata/operator/market_data_status_query.yaml
|
||||
54: expected_yyyymmdd: ["20250501"]
|
||||
60: gap_status: clean
|
||||
61: gap_count: 0
|
||||
62: duplicate_count: 0
|
||||
63: provider_delay_days: 0
|
||||
|
||||
apps/cli/testdata/operator/us_market_data_status_query.yaml
|
||||
49: expected_yyyymmdd: ["20240529"]
|
||||
55: gap_status: clean
|
||||
56: gap_count: 0
|
||||
57: duplicate_count: 0
|
||||
58: provider_delay_days: 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[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.
|
||||
|
||||
## Sections and Ownership
|
||||
|
||||
| Section | Owner | Note |
|
||||
|---------|-------|------|
|
||||
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
|
||||
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
|
||||
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only. |
|
||||
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving. |
|
||||
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
|
||||
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed. |
|
||||
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan. |
|
||||
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry. |
|
||||
| 코드리뷰 결과 | Review agent appends | Not included in stub. |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Fail
|
||||
- completeness: Fail
|
||||
- test coverage: Fail
|
||||
- API contract: Fail
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Pass
|
||||
- 발견된 문제:
|
||||
- Required: `apps/cli/internal/operator/runner.go:1281`에서 `missingSymbols` 전체를 날짜별 gap에 다시 더해, 이미 `matchedInstruments` 루프에서 `cnt == 0`으로 세어진 "매칭됐지만 bar가 0개인 심볼"을 한 번 더 카운트합니다. 예를 들어 요청 심볼 2개 중 하나가 instrument로 매칭되지만 bar가 비어 있고 `expected_yyyymmdd`가 1일이면 실제 gap은 1이어야 하는데 현재 로직은 matched instrument 루프에서 1, `missingSymbols` 루프에서 1을 더해 `gap_count=2`가 됩니다. `missingSymbols`를 unmatched symbol과 zero-bar matched symbol로 분리하거나, gap 계산을 요청 symbol별로 한 번만 순회하도록 고쳐 각 symbol/date 조합이 정확히 한 번만 세어지게 하세요. `runner_market_test.go`에는 matched-zero-bars와 unmatched-symbol이 섞인 `expected_yyyymmdd` regression을 추가해 double count를 막아야 합니다.
|
||||
- 다음 단계:
|
||||
- FAIL 후속으로 `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.md`를 작성한다.
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
<!-- task=m-backtest-data-collection-infrastructure/03+02_gap_handling plan=1 tag=REVIEW_DATASET_GAP -->
|
||||
|
||||
# Code Review Reference - REVIEW_DATASET_GAP
|
||||
|
||||
> **[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/03+02_gap_handling, plan=1, tag=REVIEW_DATASET_GAP
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
|
||||
- Task ids:
|
||||
- `gap-handling`: 결측, 휴장일, 중복, provider 지연을 백테스트 입력에서 식별할 수 있게 한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G06.md` -> `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` -> `plan_cloud_G06_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/03+02_gap_handling/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REVIEW_DATASET_GAP-1] Zero-Bar Gap Count Regression | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] matched-zero-bars symbol과 unmatched symbol이 `expected_yyyymmdd` gap 계산에서 각 symbol/date 조합당 정확히 한 번만 세어지도록 `runCollectionFreshness`를 수정한다.
|
||||
- [x] `runner_market_test.go`에 matched instrument가 bar 0개를 반환하는 케이스와 unmatched symbol이 함께 있는 regression을 추가해 `gap_count`, `gap_yyyymmdd`, `missing_count`, `missing_symbols`, `gap_status`를 검증한다.
|
||||
- [x] `go test -count=1 ./apps/cli/internal/operator -run 'TestRunCollectionFreshness|TestRunScenarioCollectionFreshnessMissingSymbol'`를 실행하고 통과 출력을 기록한다.
|
||||
- [x] `go test -count=1 ./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_cloud_G06_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리 `agent-task/m-backtest-data-collection-infrastructure/03+02_gap_handling/`를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/03+02_gap_handling/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-backtest-data-collection-infrastructure/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- 없음
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- `missingSymbols` 리스트와 `matchedInstruments` 목록의 중복 순회를 통한 결측(gap) 카운트 버그를 완벽히 해결하기 위해, 꼬리 잘림을 제외한 expected_yyyymmdd의 날짜 루프 안에서 request symbol인 `step.Request.Symbols` 단위로 직접 `symbolDateCounts` 맵의 존재 여부와 bar 개수를 평가하도록 설계했습니다. 이를 통해 unmatched symbol과 matched-zero-bars symbol 각각이 각 영업일마다 정확히 한 번만 세어지도록 보장하였습니다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- matched instrument가 bar 0개를 반환한 symbol이 gap 계산에서 한 번만 세어지는지 확인한다.
|
||||
- unmatched symbol과 matched-zero-bars symbol이 `missing_symbols`에는 모두 남되 `gap_count`를 중복 증가시키지 않는지 확인한다.
|
||||
- 기존 clean/gap/duplicate/delayed/mixed 테스트가 회귀 없이 유지되는지 확인한다.
|
||||
- Roadmap Targets가 `gap-handling` 하나만 완료 대상으로 유지되는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### REVIEW_DATASET_GAP-1 중간 검증
|
||||
```bash
|
||||
$ go test -count=1 ./apps/cli/internal/operator -run 'TestRunCollectionFreshness|TestRunScenarioCollectionFreshnessMissingSymbol'
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.010s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.204s
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/cli 0.007s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[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.
|
||||
|
||||
## Sections and Ownership
|
||||
|
||||
| Section | Owner | Note |
|
||||
|---------|-------|------|
|
||||
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
|
||||
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
|
||||
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 |
|
||||
| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
|
||||
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
|
||||
| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지 |
|
||||
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
|
||||
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움; 명령 변경은 `계획 대비 변경 사항`에 기록 |
|
||||
| 코드리뷰 결과 | 리뷰 에이전트가 append | Not included in stub |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Pass
|
||||
- 발견된 문제:
|
||||
- 없음
|
||||
- 다음 단계:
|
||||
- PASS 종결로 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# Complete - m-backtest-data-collection-infrastructure/03+02_gap_handling
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-14
|
||||
|
||||
## 요약
|
||||
|
||||
Dataset gap handling status 작업은 2회 리뷰 루프 끝에 PASS로 종결되었다. `collection_freshness` scenario가 expected trading dates 기반 gap, duplicate, provider delay를 출력/검증하며, 후속 루프에서 matched-zero-bars symbol의 gap double count regression을 수정했다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_cloud_G06_0.log` | `code_review_cloud_G06_0.log` | FAIL | matched-zero-bars symbol이 `missingSymbols` 재사용으로 gap에 중복 카운트되는 Required 이슈 발견 |
|
||||
| `plan_cloud_G06_1.log` | `code_review_cloud_G06_1.log` | PASS | request symbol 단위 gap 계산과 zero-bar regression test로 Required 이슈 해소 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- `collection_freshness` scenario schema에 `expected_yyyymmdd`와 gap expectation 필드를 추가했다.
|
||||
- `runCollectionFreshness`가 expected date별 gap, duplicate, provider delay, combined `gap_status`를 계산하고 expectation mismatch를 검증하도록 확장했다.
|
||||
- JSONL/text output, status fixtures, handoff matrix, related tests를 gap output key 계약에 맞췄다.
|
||||
- matched-zero-bars symbol과 unmatched symbol이 gap 계산에서 각 symbol/date 조합당 한 번만 세어지도록 후속 regression을 추가했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `go test -count=1 ./apps/cli/internal/operator -run 'TestRunCollectionFreshness|TestRunScenarioCollectionFreshnessMissingSymbol'` - PASS; `ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.012s`
|
||||
- `go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli` - PASS; `ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.197s`, `ok git.toki-labs.com/toki/alt/apps/cli/internal/cli 0.012s`
|
||||
- `rg --sort path -n "gap_status|gap_count|gap_yyyymmdd|duplicate_count|duplicate_yyyymmdd|provider_delay_days|expected_yyyymmdd" apps/cli/internal/operator apps/cli/testdata/operator` - PASS; schema, runner/output, tests, fixtures, and handoff matrix references were present.
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
|
||||
- Completed task ids:
|
||||
- `gap-handling`: PASS; evidence=`agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/03+02_gap_handling/plan_cloud_G06_1.log`, `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/03+02_gap_handling/code_review_cloud_G06_1.log`; verification=`go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
<!-- task=m-backtest-data-collection-infrastructure/03+02_gap_handling plan=0 tag=DATASET_GAP -->
|
||||
|
||||
# PLAN-cloud-G06 - Dataset Gap Handling Status
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
구현 완료의 마지막 단계는 active `CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증 명령을 실행하고 실제 stdout/stderr를 기록한 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log` 작성, archive 이동은 code-review-skill 전용이다.
|
||||
|
||||
구현 중 사용자만 결정할 수 있는 정책, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 충돌 때문에 안전하게 진행할 수 없으면 `CODE_REVIEW-cloud-G06.md`의 `사용자 리뷰 요청` 섹션에 정확한 결정 필요 사항, 근거, 실행한 명령과 출력, 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 채팅 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`, `complete.log`, archive log를 직접 만들지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 `collection_freshness`는 universe 안의 symbol별 bar 존재 여부와 최신 일자만 출력한다. 백테스트 입력으로 쓰려면 특정 거래일 결측, 중복 bar, provider 지연이 freshness와 별도로 드러나야 한다. 이 작업은 기존 operator scenario 계약을 확장해 headless YAML/JSONL/text 출력에서 data quality 상태를 검증할 수 있게 한다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 차단 사항은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`의 항목을 따르며, 구현 에이전트가 직접 사용자에게 묻지 않는다. code-review가 차단 근거를 검증한 뒤 필요할 때만 실제 `USER_REVIEW.md`를 작성한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
|
||||
- Task ids:
|
||||
- `gap-handling`: 결측, 휴장일, 중복, provider 지연을 백테스트 입력에서 식별할 수 있게 한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-ops/skills/common/plan/SKILL.md`
|
||||
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/operations-smoke.md`
|
||||
- `agent-test/local/domain-model-smoke.md`
|
||||
- `agent-test/local/worker-smoke.md`
|
||||
- `agent-ops/rules/private/kis-api-local-cache.md`
|
||||
- `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
|
||||
- `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/complete.log`
|
||||
- `apps/cli/internal/operator/scenario.go`
|
||||
- `apps/cli/internal/operator/runner.go`
|
||||
- `apps/cli/internal/operator/output.go`
|
||||
- `apps/cli/internal/operator/runner_market_test.go`
|
||||
- `apps/cli/internal/operator/scenario_test.go`
|
||||
- `apps/cli/internal/operator/handoff_test.go`
|
||||
- `apps/cli/testdata/operator/market_data_status_query.yaml`
|
||||
- `apps/cli/testdata/operator/us_market_data_status_query.yaml`
|
||||
- `apps/cli/testdata/operator/expected/market_data_status_query.jsonl`
|
||||
- `apps/cli/testdata/operator/expected/us_market_data_status_query.jsonl`
|
||||
- `apps/cli/testdata/operator/headless_validation.md`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
`test_env=local`을 적용했다. `agent-test/local/rules.md`가 존재해 읽었고, 관련 프로필로 `operations-smoke.md`, `domain-model-smoke.md`, `worker-smoke.md`를 읽었다. 이번 계획의 직접 검증은 operator headless scenario 계약이므로 `operations-smoke.md`의 CLI/operator 중심 검증을 적용하고, 최종 명령은 기존 freshness 작업과 같은 `go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli`를 사용한다. `<확인 필요>` 값이나 구조적으로 빈 테스트 규칙은 없었다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- scenario schema: `expect.freshness_status`, `missing_count`, `latest_yyyymmdd` 검증은 있으나 `request.expected_yyyymmdd`, `expect.gap_status`, `gap_count`, `duplicate_count`, `provider_delay_days` 검증은 없다. 새 validation 단위 테스트가 필요하다.
|
||||
- runner freshness: `fresh`, `missing`, `error` 케이스는 `runner_market_test.go`에 있으나 trading date gap, duplicate daily bar, provider delay 판정은 없다. 새 runner 테스트가 필요하다.
|
||||
- output/handoff: 기존 JSONL/text/handoff matrix는 `freshness_status`, `missing_count`, `latest_yyyymmdd`만 약속한다. 새 출력 key와 fixture 검증 업데이트가 필요하다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
renamed/removed symbol은 없다. `collection_freshness`, `FreshnessStatus`, `MissingSymbols`, `LatestYYYYMMDD` 참조는 `apps/cli/internal/operator/scenario.go`, `runner.go`, `output.go`, `runner_market_test.go`, `scenario_test.go`, `handoff_test.go`, `apps/cli/testdata/operator/*market_data_status_query*`, `apps/cli/testdata/operator/headless_validation.md`에 분포한다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
split decision policy를 먼저 평가했다. 이 작업은 기존 `freshness-status` 구현 위의 call-site 확장이므로 `03+02_gap_handling` 단일 dependent subtask로 둔다. 선행 index `02`는 `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/complete.log`로 충족된다.
|
||||
|
||||
별도 API/foundation subtask는 만들지 않는다. protobuf, API service, worker storage schema를 바꾸지 않고 CLI operator scenario schema와 output event만 확장하기 때문이다. 테스트 전략도 scenario validation, runner, output fixture가 같은 operator package 안에서 한 번에 검토 가능하고, 분리하면 fixture/output 계약 조율 비용이 더 커진다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
포함 범위는 `apps/cli/internal/operator`의 scenario schema/validation, `collection_freshness` runner 계산, output serialization/text 렌더링, operator fixtures, handoff matrix, 관련 테스트다. `packages/contracts`, `services/api`, `services/worker`, PostgreSQL schema/migration, provider adapter 구현, 실제 휴장일 캘린더 연동은 제외한다. 휴장일은 이번 작업에서 `request.expected_yyyymmdd`가 명시한 거래일 목록으로 표현하고, 외부 exchange calendar 선택은 후속 data quality monitoring 범위로 남긴다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
`cloud-G06`으로 둔다. 변경 파일은 operator 내부로 제한되지만, CLI/YAML/JSONL/text/handoff 계약과 여러 테스트가 함께 맞아야 하며 기존 freshness 완료 근거와 roadmap completion anchor를 유지해야 한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `collection_freshness` scenario schema와 validation에 기대 거래일 및 gap expectation 필드를 추가한다.
|
||||
- [ ] `runCollectionFreshness`에서 결측 거래일, 중복 일자, provider 지연을 계산하고 expectation mismatch를 검증한다.
|
||||
- [ ] `StepEvent` JSONL/text 출력과 status fixtures, handoff matrix, handoff tests를 새 key 계약에 맞춘다.
|
||||
- [ ] `go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli`를 실행하고 통과 출력을 기록한다.
|
||||
- [ ] `rg --sort path -n "gap_status|gap_count|gap_yyyymmdd|duplicate_count|duplicate_yyyymmdd|provider_delay_days|expected_yyyymmdd" apps/cli/internal/operator apps/cli/testdata/operator`를 실행해 새 계약 위치를 확인한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [DATASET_GAP-1] Scenario Gap Contract
|
||||
|
||||
#### 문제
|
||||
|
||||
`apps/cli/internal/operator/scenario.go:251-256`의 `Expect`는 freshness status, missing count, latest date만 표현한다.
|
||||
|
||||
```go
|
||||
// apps/cli/internal/operator/scenario.go:251
|
||||
FreshnessStatus string `yaml:"freshness_status"`
|
||||
MissingCount *int `yaml:"missing_count"`
|
||||
LatestYYYYMMDD string `yaml:"latest_yyyymmdd"`
|
||||
```
|
||||
|
||||
`apps/cli/internal/operator/scenario.go:534-556` validation도 freshness 값과 필수 request window까지만 검사한다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
`Request`에 명시 거래일 목록을 추가하고 `Expect`에 gap 결과 expectation을 추가한다.
|
||||
|
||||
```go
|
||||
// after
|
||||
ExpectedYYYYMMDD []string `yaml:"expected_yyyymmdd"`
|
||||
|
||||
GapStatus string `yaml:"gap_status"` // clean, gap, duplicate, delayed, mixed, error
|
||||
GapCount *int `yaml:"gap_count"`
|
||||
DuplicateCount *int `yaml:"duplicate_count"`
|
||||
ProviderDelayDays *int `yaml:"provider_delay_days"`
|
||||
```
|
||||
|
||||
validation은 daily timeframe의 `collection_freshness`에서 `expected_yyyymmdd`가 `YYYYMMDD`, 오름차순, 중복 없음, request window 안인지 확인한다. `gap_status`는 `clean`, `gap`, `duplicate`, `delayed`, `mixed`, `error`만 허용한다. 명시 거래일이 없으면 gap 계산은 기존 freshness 동작을 유지하고 `gap_status` expectation이 있을 때만 mismatch를 낸다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/cli/internal/operator/scenario.go`: request/expect field와 validation 추가.
|
||||
- [ ] `apps/cli/internal/operator/scenario_test.go`: malformed date, unsorted/duplicate date, unsupported gap status, valid gap contract 테스트 추가.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
`apps/cli/internal/operator/scenario_test.go`에 validation regression tests를 작성한다. assertion 목표는 잘못된 `expected_yyyymmdd`와 `expect.gap_status`가 parse/validation 단계에서 exit code `2` 성격의 오류로 막히는 것이다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/cli/internal/operator -run 'TestValidate.*(Gap|ExpectedYYYYMMDD|Freshness)'
|
||||
```
|
||||
|
||||
예상: operator package PASS.
|
||||
|
||||
### [DATASET_GAP-2] Runner Gap Computation
|
||||
|
||||
#### 문제
|
||||
|
||||
`apps/cli/internal/operator/runner.go:1145-1268`의 `runCollectionFreshness`는 symbol별 bar 존재 여부와 전체 latest timestamp만 계산한다. bar 날짜별 결측, 중복, 지연을 계산하지 않는다.
|
||||
|
||||
```go
|
||||
// apps/cli/internal/operator/runner.go:1231
|
||||
ev.MissingSymbols = missingSymbols
|
||||
ev.MissingCount = len(missingSymbols)
|
||||
|
||||
// apps/cli/internal/operator/runner.go:1240
|
||||
if len(missingSymbols) == 0 {
|
||||
ev.FreshnessStatus = "fresh"
|
||||
} else {
|
||||
ev.FreshnessStatus = "missing"
|
||||
}
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
`collection_freshness`가 bar 조회 중 symbol별 `YYYYMMDD -> count`를 모으게 한다. `request.expected_yyyymmdd`가 있으면 각 expected date가 모든 matched symbol에 존재하는지 검사해 `gap_yyyymmdd`와 `gap_count`를 계산한다. 같은 symbol/date에 bar가 2개 이상이면 `duplicate_yyyymmdd`와 `duplicate_count`를 계산한다. provider delay는 expected list의 마지막 일자와 실제 latest 일자의 expected-list index 차이로 계산한다. latest가 expected list 밖이면 calendar day 차이로 fallback하되 review stub에 실제 선택을 기록한다.
|
||||
|
||||
`gap_status`는 다음 우선순위로 산출한다.
|
||||
|
||||
```text
|
||||
error > mixed > gap > duplicate > delayed > clean
|
||||
```
|
||||
|
||||
`mixed`는 gap/duplicate/delayed 중 둘 이상이 동시에 있는 경우다. 기존 `freshness_status`는 symbol-level missing 여부를 유지한다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/cli/internal/operator/runner.go`: 날짜 집계 helper, gap status 계산, expectation mismatch 메시지 추가.
|
||||
- [ ] `apps/cli/internal/operator/runner_market_test.go`: clean, gap, duplicate, delayed, mixed 중 최소 clean/gap/duplicate/delayed 테스트 추가.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
`apps/cli/internal/operator/runner_market_test.go`에 fake API client 기반 regression tests를 작성한다. assertion 목표는 JSONL/text output에 `gap_status`, `gap_count`, `duplicate_count`, `provider_delay_days`, `gap_yyyymmdd`, `duplicate_yyyymmdd`가 의도대로 나타나는 것이다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/cli/internal/operator -run 'TestRunCollectionFreshness'
|
||||
```
|
||||
|
||||
예상: operator package PASS.
|
||||
|
||||
### [DATASET_GAP-3] Output Fixtures And Handoff
|
||||
|
||||
#### 문제
|
||||
|
||||
`apps/cli/internal/operator/output.go:193-203`과 `output.go:330-340`은 freshness key만 출력한다. `apps/cli/testdata/operator/headless_validation.md:33-34`도 matrix expected keys를 freshness 중심으로 약속한다.
|
||||
|
||||
```go
|
||||
// apps/cli/internal/operator/output.go:193
|
||||
if ev.FreshnessStatus != "" {
|
||||
fields["freshness_status"] = ev.FreshnessStatus
|
||||
}
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
`StepEvent`에 gap output fields를 추가하고 JSONL/text 렌더링에 포함한다. KR/US status fixtures의 `freshness_status` step에 `expected_yyyymmdd`와 clean gap expectations를 추가하고 expected JSONL에 새 key를 반영한다. handoff matrix와 `handoff_test.go` required key 목록도 같은 key 세트를 약속하게 한다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/cli/internal/operator/output.go`: `GapStatus`, `GapCount`, `GapYYYYMMDD`, `DuplicateCount`, `DuplicateYYYYMMDD`, `ProviderDelayDays` 필드와 serialization/text 추가.
|
||||
- [ ] `apps/cli/testdata/operator/market_data_status_query.yaml`: KR clean gap expectation 추가.
|
||||
- [ ] `apps/cli/testdata/operator/us_market_data_status_query.yaml`: US clean gap expectation 추가.
|
||||
- [ ] `apps/cli/testdata/operator/expected/market_data_status_query.jsonl`: expected JSONL 갱신.
|
||||
- [ ] `apps/cli/testdata/operator/expected/us_market_data_status_query.jsonl`: expected JSONL 갱신.
|
||||
- [ ] `apps/cli/testdata/operator/headless_validation.md`: matrix expected keys와 checked field 갱신.
|
||||
- [ ] `apps/cli/internal/operator/handoff_test.go`: required key 목록 갱신.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
기존 fixture/handoff tests를 갱신하고, 새 fixture line에 key가 누락되면 실패하도록 유지한다. 별도 새 package test 파일은 만들지 않는다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/cli/internal/operator
|
||||
```
|
||||
|
||||
예상: operator package PASS.
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
이 subtask 디렉터리 이름 `03+02_gap_handling`의 predecessor index는 `02`다. `02`는 `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/complete.log`로 완료되었다. 구현 순서는 DATASET_GAP-1, DATASET_GAP-2, DATASET_GAP-3 순서로 진행한다.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|------|------|
|
||||
| `apps/cli/internal/operator/scenario.go` | DATASET_GAP-1 |
|
||||
| `apps/cli/internal/operator/scenario_test.go` | DATASET_GAP-1 |
|
||||
| `apps/cli/internal/operator/runner.go` | DATASET_GAP-2 |
|
||||
| `apps/cli/internal/operator/runner_market_test.go` | DATASET_GAP-2 |
|
||||
| `apps/cli/internal/operator/output.go` | DATASET_GAP-3 |
|
||||
| `apps/cli/internal/operator/handoff_test.go` | DATASET_GAP-3 |
|
||||
| `apps/cli/testdata/operator/market_data_status_query.yaml` | DATASET_GAP-3 |
|
||||
| `apps/cli/testdata/operator/us_market_data_status_query.yaml` | DATASET_GAP-3 |
|
||||
| `apps/cli/testdata/operator/expected/market_data_status_query.jsonl` | DATASET_GAP-3 |
|
||||
| `apps/cli/testdata/operator/expected/us_market_data_status_query.jsonl` | DATASET_GAP-3 |
|
||||
| `apps/cli/testdata/operator/headless_validation.md` | DATASET_GAP-3 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli
|
||||
```
|
||||
|
||||
예상: 두 package 모두 PASS. Go test cache output은 허용하지 않는다. 반드시 `-count=1`로 fresh run을 기록한다.
|
||||
|
||||
```bash
|
||||
rg --sort path -n "gap_status|gap_count|gap_yyyymmdd|duplicate_count|duplicate_yyyymmdd|provider_delay_days|expected_yyyymmdd" apps/cli/internal/operator apps/cli/testdata/operator
|
||||
```
|
||||
|
||||
예상: 새 schema, runner/output, tests, fixtures, handoff matrix 위치가 안정 정렬로 출력된다.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<!-- task=m-backtest-data-collection-infrastructure/03+02_gap_handling plan=1 tag=REVIEW_DATASET_GAP -->
|
||||
|
||||
# PLAN-cloud-G06 - Review Follow-up: Dataset Gap Counting
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
구현 완료의 마지막 단계는 active `CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증 명령을 실행하고 실제 stdout/stderr를 기록한 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log` 작성, archive 이동은 code-review-skill 전용이다.
|
||||
|
||||
구현 중 사용자만 결정할 수 있는 정책, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 충돌 때문에 안전하게 진행할 수 없으면 `CODE_REVIEW-cloud-G06.md`의 `사용자 리뷰 요청` 섹션에 정확한 결정 필요 사항, 근거, 실행한 명령과 출력, 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 채팅 선택지를 제시하거나 `request_user_input`을 호출하거나, `USER_REVIEW.md`, `complete.log`, archive log를 직접 만들지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
|
||||
|
||||
## 배경
|
||||
|
||||
첫 리뷰는 `apps/cli/internal/operator/runner.go:1281`의 gap 계산 double count 문제로 `FAIL` 판정됐다. 현재 구현은 bar가 0개인 matched instrument를 `missingSymbols`에 넣은 뒤, expected date 루프에서 matched instrument의 `cnt == 0`으로 한 번 세고 `missingSymbols` 전체를 다시 더해 한 번 더 세운다. 결과적으로 일부 symbol은 instrument로 매칭됐지만 bar만 비어 있는 정상적인 freshness-missing 케이스에서 `gap_count`와 `gap_status` 계약이 실제보다 과장될 수 있다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
|
||||
- Task ids:
|
||||
- `gap-handling`: 결측, 휴장일, 중복, provider 지연을 백테스트 입력에서 식별할 수 있게 한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 범위 결정 근거
|
||||
|
||||
이번 후속은 기존 `collection_freshness` gap 계산의 카운팅 정확도와 그 regression test만 다룬다. scenario schema, output key 이름, fixture 계약, roadmap 문서는 새로 넓히지 않는다. 범위 밖 변경이 작업 트리에 있어도 이 후속의 수정 대상은 `apps/cli/internal/operator/runner.go`와 `apps/cli/internal/operator/runner_market_test.go`로 제한한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] matched-zero-bars symbol과 unmatched symbol이 `expected_yyyymmdd` gap 계산에서 각 symbol/date 조합당 정확히 한 번만 세어지도록 `runCollectionFreshness`를 수정한다.
|
||||
- [ ] `runner_market_test.go`에 matched instrument가 bar 0개를 반환하는 케이스와 unmatched symbol이 함께 있는 regression을 추가해 `gap_count`, `gap_yyyymmdd`, `missing_count`, `missing_symbols`, `gap_status`를 검증한다.
|
||||
- [ ] `go test -count=1 ./apps/cli/internal/operator -run 'TestRunCollectionFreshness|TestRunScenarioCollectionFreshnessMissingSymbol'`를 실행하고 통과 출력을 기록한다.
|
||||
- [ ] `go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli`를 실행하고 통과 출력을 기록한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [REVIEW_DATASET_GAP-1] Zero-Bar Gap Count Regression
|
||||
|
||||
#### 문제
|
||||
|
||||
`runCollectionFreshness`는 matched instrument가 bar 0개를 반환하면 해당 symbol을 `missingSymbols`에 추가한다. 이후 gap 계산에서 matched instrument loop가 `cnt == 0`을 보고 gap을 1회 추가하고, 같은 expected date에 대해 `missingSymbols` loop가 다시 gap을 추가한다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
gap 계산 입력을 symbol 단위로 정리한다. 한 가지 안전한 방식은 unmatched symbol만 별도 목록으로 유지하고, matched instrument는 `symbolDateCounts`에서만 계산하는 것이다. 또는 request symbol별로 단일 루프를 만들고 각 symbol/date 조합을 정확히 한 번만 평가한다. 어느 방식이든 matched-zero-bars symbol, unmatched symbol, partial date gap, duplicate date가 서로 중복 카운트되지 않아야 한다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/cli/internal/operator/runner.go`: `missingSymbols` 재사용으로 인한 double count를 제거한다.
|
||||
- [ ] `apps/cli/internal/operator/runner_market_test.go`: matched-zero-bars + unmatched symbol regression을 추가한다.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
`runner_market_test.go`의 freshness tests에 다음 성격의 케이스를 추가한다.
|
||||
|
||||
- universe symbols: `005930`, `000660`, `000020`
|
||||
- instrument list: `005930`, `000660`만 반환
|
||||
- bars: `005930`은 expected date에 bar 1개, `000660`은 bar 0개
|
||||
- expected dates: 단일 date 또는 두 date 중 delay가 섞이지 않는 범위
|
||||
- expected assertions: `missing_count=2`, `missing_symbols=000020,000660`, `gap_status=gap`, `gap_count=2` for one expected date, `gap_yyyymmdd=<date>`; 같은 symbol/date가 중복되어 `gap_count=3` 이상이 되면 실패해야 한다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/cli/internal/operator -run 'TestRunCollectionFreshness|TestRunScenarioCollectionFreshnessMissingSymbol'
|
||||
```
|
||||
|
||||
예상: operator package PASS.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli
|
||||
```
|
||||
|
||||
예상: 두 package 모두 PASS. Go test cache output은 허용하지 않는다. 반드시 `-count=1`로 fresh run을 기록한다.
|
||||
|
|
@ -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", "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"},
|
||||
"market_data_status_query": {"provider", "instrument_count", "bar_count", "count", "universe", "freshness_status", "missing_count", "latest_yyyymmdd", "gap_status", "gap_count", "duplicate_count", "provider_delay_days"},
|
||||
"us_market_data_status_query": {"provider", "instrument_count", "bar_count", "count", "universe", "freshness_status", "missing_count", "latest_yyyymmdd", "gap_status", "gap_count", "duplicate_count", "provider_delay_days"},
|
||||
"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"},
|
||||
|
|
|
|||
|
|
@ -51,6 +51,14 @@ type StepEvent struct {
|
|||
MissingCount int
|
||||
MissingSymbols []string
|
||||
|
||||
// Gap-specific output fields
|
||||
GapStatus string
|
||||
GapCount int
|
||||
GapYYYYMMDD []string
|
||||
DuplicateCount int
|
||||
DuplicateYYYYMMDD []string
|
||||
ProviderDelayDays int
|
||||
|
||||
// Paper-trading-specific output fields. They are only rendered when
|
||||
// AccountID is set, so non-paper steps never emit them.
|
||||
AccountID string
|
||||
|
|
@ -202,6 +210,24 @@ func (o *Writer) WriteStep(ev StepEvent) {
|
|||
if len(ev.MissingSymbols) > 0 {
|
||||
fields["missing_symbols"] = ev.MissingSymbols
|
||||
}
|
||||
if ev.GapStatus != "" {
|
||||
fields["gap_status"] = ev.GapStatus
|
||||
if ev.GapCount >= 0 {
|
||||
fields["gap_count"] = ev.GapCount
|
||||
}
|
||||
if len(ev.GapYYYYMMDD) > 0 {
|
||||
fields["gap_yyyymmdd"] = ev.GapYYYYMMDD
|
||||
}
|
||||
if ev.DuplicateCount >= 0 {
|
||||
fields["duplicate_count"] = ev.DuplicateCount
|
||||
}
|
||||
if len(ev.DuplicateYYYYMMDD) > 0 {
|
||||
fields["duplicate_yyyymmdd"] = ev.DuplicateYYYYMMDD
|
||||
}
|
||||
if ev.ProviderDelayDays >= 0 {
|
||||
fields["provider_delay_days"] = ev.ProviderDelayDays
|
||||
}
|
||||
}
|
||||
if ev.AccountID != "" {
|
||||
fields["account_id"] = ev.AccountID
|
||||
if ev.Cash != "" {
|
||||
|
|
@ -339,6 +365,24 @@ func (o *Writer) WriteStep(ev StepEvent) {
|
|||
if len(ev.MissingSymbols) > 0 {
|
||||
line += fmt.Sprintf(" missing_symbols=%s", strings.Join(ev.MissingSymbols, ","))
|
||||
}
|
||||
if ev.GapStatus != "" {
|
||||
line += fmt.Sprintf(" gap_status=%s", ev.GapStatus)
|
||||
if ev.GapCount >= 0 {
|
||||
line += fmt.Sprintf(" gap_count=%d", ev.GapCount)
|
||||
}
|
||||
if len(ev.GapYYYYMMDD) > 0 {
|
||||
line += fmt.Sprintf(" gap_yyyymmdd=%s", strings.Join(ev.GapYYYYMMDD, ","))
|
||||
}
|
||||
if ev.DuplicateCount >= 0 {
|
||||
line += fmt.Sprintf(" duplicate_count=%d", ev.DuplicateCount)
|
||||
}
|
||||
if len(ev.DuplicateYYYYMMDD) > 0 {
|
||||
line += fmt.Sprintf(" duplicate_yyyymmdd=%s", strings.Join(ev.DuplicateYYYYMMDD, ","))
|
||||
}
|
||||
if ev.ProviderDelayDays >= 0 {
|
||||
line += fmt.Sprintf(" provider_delay_days=%d", ev.ProviderDelayDays)
|
||||
}
|
||||
}
|
||||
if ev.AccountID != "" {
|
||||
line += fmt.Sprintf(" account_id=%s", ev.AccountID)
|
||||
if ev.Cash != "" {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -1144,13 +1145,16 @@ func interpolateSlice(slice []string, savedValues map[string]string) []string {
|
|||
|
||||
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,
|
||||
Scenario: scenario,
|
||||
Step: step.ID,
|
||||
Action: string(step.Action),
|
||||
Count: noCount,
|
||||
InstrumentCount: noCount,
|
||||
BarCount: noCount,
|
||||
MissingCount: noCount,
|
||||
GapCount: noCount,
|
||||
DuplicateCount: noCount,
|
||||
ProviderDelayDays: noCount,
|
||||
}
|
||||
|
||||
resp, err := client.ListInstruments(ctx, &altv1.ListInstrumentsRequest{
|
||||
|
|
@ -1193,6 +1197,7 @@ func runCollectionFreshness(ctx context.Context, client *APIClient, scenario str
|
|||
}
|
||||
|
||||
var latestUnixMs int64 = 0
|
||||
symbolDateCounts := make(map[string]map[string]int)
|
||||
|
||||
for _, inst := range matchedInstruments {
|
||||
barsResp, err := client.ListBars(ctx, &altv1.ListBarsRequest{
|
||||
|
|
@ -1216,24 +1221,31 @@ func runCollectionFreshness(ctx context.Context, client *APIClient, scenario str
|
|||
instRequestSymbol = inst.GetSymbol()
|
||||
}
|
||||
|
||||
dateCounts := make(map[string]int)
|
||||
if len(barsResp.GetBars()) == 0 {
|
||||
missingSymbols = append(missingSymbols, instRequestSymbol)
|
||||
} else {
|
||||
for _, bar := range barsResp.GetBars() {
|
||||
t := time.UnixMilli(bar.GetTimestampUnixMs()).UTC()
|
||||
d := t.Format("20060102")
|
||||
dateCounts[d]++
|
||||
if bar.GetTimestampUnixMs() > latestUnixMs {
|
||||
latestUnixMs = bar.GetTimestampUnixMs()
|
||||
}
|
||||
}
|
||||
}
|
||||
symbolDateCounts[instRequestSymbol] = dateCounts
|
||||
}
|
||||
|
||||
ev.Universe = step.Request.Universe
|
||||
ev.MissingSymbols = missingSymbols
|
||||
ev.MissingCount = len(missingSymbols)
|
||||
|
||||
var latest string
|
||||
if latestUnixMs > 0 {
|
||||
t := time.UnixMilli(latestUnixMs).UTC()
|
||||
ev.LatestYYYYMMDD = t.Format("20060102")
|
||||
latest = t.Format("20060102")
|
||||
ev.LatestYYYYMMDD = latest
|
||||
}
|
||||
|
||||
if len(missingSymbols) == 0 {
|
||||
|
|
@ -1242,6 +1254,107 @@ func runCollectionFreshness(ctx context.Context, client *APIClient, scenario str
|
|||
ev.FreshnessStatus = "missing"
|
||||
}
|
||||
|
||||
if len(step.Request.ExpectedYYYYMMDD) > 0 {
|
||||
gapCount := 0
|
||||
duplicateCount := 0
|
||||
gapDatesMap := make(map[string]bool)
|
||||
dupDatesMap := make(map[string]bool)
|
||||
|
||||
for _, d := range step.Request.ExpectedYYYYMMDD {
|
||||
if latest != "" && d > latest {
|
||||
continue
|
||||
}
|
||||
for _, sym := range step.Request.Symbols {
|
||||
dateCounts, matched := symbolDateCounts[sym]
|
||||
if !matched {
|
||||
gapCount++
|
||||
gapDatesMap[d] = true
|
||||
} else {
|
||||
cnt := dateCounts[d]
|
||||
if cnt == 0 {
|
||||
gapCount++
|
||||
gapDatesMap[d] = true
|
||||
} else if cnt >= 2 {
|
||||
duplicateCount++
|
||||
dupDatesMap[d] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gapDates := sortMapKeys(gapDatesMap)
|
||||
dupDates := sortMapKeys(dupDatesMap)
|
||||
|
||||
ev.GapCount = gapCount
|
||||
ev.GapYYYYMMDD = gapDates
|
||||
ev.DuplicateCount = duplicateCount
|
||||
ev.DuplicateYYYYMMDD = dupDates
|
||||
|
||||
expectedLast := step.Request.ExpectedYYYYMMDD[len(step.Request.ExpectedYYYYMMDD)-1]
|
||||
var delayDays int
|
||||
if latest == "" {
|
||||
firstDate := step.Request.ExpectedYYYYMMDD[0]
|
||||
tFirst, _ := time.ParseInLocation("20060102", firstDate, time.UTC)
|
||||
tLast, _ := time.ParseInLocation("20060102", expectedLast, time.UTC)
|
||||
diff := int(tLast.Sub(tFirst) / (24 * time.Hour))
|
||||
if diff < 0 {
|
||||
diff = 0
|
||||
}
|
||||
delayDays = diff
|
||||
} else {
|
||||
latestIdx := -1
|
||||
for idx, d := range step.Request.ExpectedYYYYMMDD {
|
||||
if d == latest {
|
||||
latestIdx = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
if latestIdx != -1 {
|
||||
delayDays = (len(step.Request.ExpectedYYYYMMDD) - 1) - latestIdx
|
||||
} else {
|
||||
tLast, _ := time.ParseInLocation("20060102", expectedLast, time.UTC)
|
||||
tLatest, err := time.ParseInLocation("20060102", latest, time.UTC)
|
||||
if err != nil {
|
||||
delayDays = 0
|
||||
} else {
|
||||
diff := int(tLast.Sub(tLatest) / (24 * time.Hour))
|
||||
if diff < 0 {
|
||||
diff = 0
|
||||
}
|
||||
delayDays = diff
|
||||
}
|
||||
}
|
||||
}
|
||||
ev.ProviderDelayDays = delayDays
|
||||
|
||||
hasGap := gapCount > 0
|
||||
hasDup := duplicateCount > 0
|
||||
hasDelay := delayDays > 0
|
||||
|
||||
activeConditions := 0
|
||||
if hasGap {
|
||||
activeConditions++
|
||||
}
|
||||
if hasDup {
|
||||
activeConditions++
|
||||
}
|
||||
if hasDelay {
|
||||
activeConditions++
|
||||
}
|
||||
|
||||
if activeConditions >= 2 {
|
||||
ev.GapStatus = "mixed"
|
||||
} else if hasGap {
|
||||
ev.GapStatus = "gap"
|
||||
} else if hasDup {
|
||||
ev.GapStatus = "duplicate"
|
||||
} else if hasDelay {
|
||||
ev.GapStatus = "delayed"
|
||||
} else {
|
||||
ev.GapStatus = "clean"
|
||||
}
|
||||
}
|
||||
|
||||
expectsError := step.Expect.Status == statusError
|
||||
if expectsError {
|
||||
ev.Status = statusMismatch
|
||||
|
|
@ -1265,6 +1378,27 @@ func runCollectionFreshness(ctx context.Context, client *APIClient, scenario str
|
|||
return ev, codeAppError, ""
|
||||
}
|
||||
|
||||
if step.Expect.GapStatus != "" && ev.GapStatus != step.Expect.GapStatus {
|
||||
ev.Status = statusMismatch
|
||||
ev.ErrorMessage = fmt.Sprintf("expected gap_status %q, got %q", step.Expect.GapStatus, ev.GapStatus)
|
||||
return ev, codeAppError, ""
|
||||
}
|
||||
if step.Expect.GapCount != nil && ev.GapCount != *step.Expect.GapCount {
|
||||
ev.Status = statusMismatch
|
||||
ev.ErrorMessage = fmt.Sprintf("expected gap_count %d, got %d", *step.Expect.GapCount, ev.GapCount)
|
||||
return ev, codeAppError, ""
|
||||
}
|
||||
if step.Expect.DuplicateCount != nil && ev.DuplicateCount != *step.Expect.DuplicateCount {
|
||||
ev.Status = statusMismatch
|
||||
ev.ErrorMessage = fmt.Sprintf("expected duplicate_count %d, got %d", *step.Expect.DuplicateCount, ev.DuplicateCount)
|
||||
return ev, codeAppError, ""
|
||||
}
|
||||
if step.Expect.ProviderDelayDays != nil && ev.ProviderDelayDays != *step.Expect.ProviderDelayDays {
|
||||
ev.Status = statusMismatch
|
||||
ev.ErrorMessage = fmt.Sprintf("expected provider_delay_days %d, got %d", *step.Expect.ProviderDelayDays, ev.ProviderDelayDays)
|
||||
return ev, codeAppError, ""
|
||||
}
|
||||
|
||||
ev.Status = statusOK
|
||||
return ev, codeOK, ""
|
||||
}
|
||||
|
|
@ -1275,6 +1409,10 @@ func evaluateFreshnessError(ev StepEvent, step Step, errInfo *altv1.ErrorInfo) (
|
|||
ev.ErrorCode = errInfo.GetCode()
|
||||
ev.ErrorMessage = errInfo.GetMessage()
|
||||
|
||||
if len(step.Request.ExpectedYYYYMMDD) > 0 {
|
||||
ev.GapStatus = "error"
|
||||
}
|
||||
|
||||
if !expectsError {
|
||||
ev.Status = statusError
|
||||
return ev, codeAppError, ""
|
||||
|
|
@ -1288,6 +1426,22 @@ func evaluateFreshnessError(ev StepEvent, step Step, errInfo *altv1.ErrorInfo) (
|
|||
ev.ErrorMessage = fmt.Sprintf("expected freshness_status %q, got %q", step.Expect.FreshnessStatus, "error")
|
||||
return ev, codeAppError, ""
|
||||
}
|
||||
|
||||
if step.Expect.GapStatus != "" && step.Expect.GapStatus != "error" {
|
||||
ev.Status = statusMismatch
|
||||
ev.ErrorMessage = fmt.Sprintf("expected gap_status %q, got %q", step.Expect.GapStatus, "error")
|
||||
return ev, codeAppError, ""
|
||||
}
|
||||
|
||||
ev.Status = statusOK
|
||||
return ev, codeOK, ""
|
||||
}
|
||||
|
||||
func sortMapKeys(m map[string]bool) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
|
|
|||
|
|
@ -754,3 +754,263 @@ func TestRunScenarioCollectionFreshnessTypedError(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCollectionFreshness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
barsFunc func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error)
|
||||
expectedYYYYMMDD []string
|
||||
expectGapStatus string
|
||||
expectGapCount int
|
||||
expectDuplicateCount int
|
||||
expectDelayDays int
|
||||
}{
|
||||
{
|
||||
name: "clean",
|
||||
barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
|
||||
return &altv1.ListBarsResponse{
|
||||
Bars: []*altv1.Bar{
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
expectedYYYYMMDD: []string{"20240527", "20240528"},
|
||||
expectGapStatus: "clean",
|
||||
expectGapCount: 0,
|
||||
expectDuplicateCount: 0,
|
||||
expectDelayDays: 0,
|
||||
},
|
||||
{
|
||||
name: "gap",
|
||||
barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
|
||||
if req.GetInstrumentId() == "KRX:005930" {
|
||||
return &altv1.ListBarsResponse{
|
||||
Bars: []*altv1.Bar{
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return &altv1.ListBarsResponse{
|
||||
Bars: []*altv1.Bar{
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
expectedYYYYMMDD: []string{"20240527", "20240528"},
|
||||
expectGapStatus: "gap",
|
||||
expectGapCount: 1,
|
||||
expectDuplicateCount: 0,
|
||||
expectDelayDays: 0,
|
||||
},
|
||||
{
|
||||
name: "duplicate",
|
||||
barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
|
||||
if req.GetInstrumentId() == "KRX:000660" {
|
||||
return &altv1.ListBarsResponse{
|
||||
Bars: []*altv1.Bar{
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return &altv1.ListBarsResponse{
|
||||
Bars: []*altv1.Bar{
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
expectedYYYYMMDD: []string{"20240527", "20240528"},
|
||||
expectGapStatus: "duplicate",
|
||||
expectGapCount: 0,
|
||||
expectDuplicateCount: 1,
|
||||
expectDelayDays: 0,
|
||||
},
|
||||
{
|
||||
name: "delayed",
|
||||
barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
|
||||
return &altv1.ListBarsResponse{
|
||||
Bars: []*altv1.Bar{
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
expectedYYYYMMDD: []string{"20240527", "20240528", "20240529"},
|
||||
expectGapStatus: "delayed",
|
||||
expectGapCount: 0,
|
||||
expectDuplicateCount: 0,
|
||||
expectDelayDays: 1,
|
||||
},
|
||||
{
|
||||
name: "mixed",
|
||||
barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
|
||||
if req.GetInstrumentId() == "KRX:005930" {
|
||||
return &altv1.ListBarsResponse{
|
||||
Bars: []*altv1.Bar{
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return &altv1.ListBarsResponse{
|
||||
Bars: []*altv1.Bar{
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
|
||||
{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
expectedYYYYMMDD: []string{"20240527", "20240528", "20240529"},
|
||||
expectGapStatus: "mixed",
|
||||
expectGapCount: 1,
|
||||
expectDuplicateCount: 0,
|
||||
expectDelayDays: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(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: tc.barsFunc,
|
||||
}
|
||||
url := startFakeAPIServer(t, api)
|
||||
sc := &Scenario{
|
||||
Name: "collection_freshness_gap_test",
|
||||
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: 1716940800000,
|
||||
ExpectedYYYYMMDD: tc.expectedYYYYMMDD,
|
||||
},
|
||||
Expect: Expect{
|
||||
Status: "ok",
|
||||
GapStatus: tc.expectGapStatus,
|
||||
GapCount: intPtr(tc.expectGapCount),
|
||||
DuplicateCount: intPtr(tc.expectDuplicateCount),
|
||||
ProviderDelayDays: intPtr(tc.expectDelayDays),
|
||||
},
|
||||
}},
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, "gap_status="+tc.expectGapStatus) {
|
||||
t.Errorf("output %q missing gap_status=%s", out, tc.expectGapStatus)
|
||||
}
|
||||
if !strings.Contains(out, "gap_count="+itoa(tc.expectGapCount)) {
|
||||
t.Errorf("output %q missing gap_count=%d", out, tc.expectGapCount)
|
||||
}
|
||||
if !strings.Contains(out, "duplicate_count="+itoa(tc.expectDuplicateCount)) {
|
||||
t.Errorf("output %q missing duplicate_count=%d", out, tc.expectDuplicateCount)
|
||||
}
|
||||
if !strings.Contains(out, "provider_delay_days="+itoa(tc.expectDelayDays)) {
|
||||
t.Errorf("output %q missing provider_delay_days=%d", out, tc.expectDelayDays)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCollectionFreshnessZeroBarRegression(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{
|
||||
Bars: []*altv1.Bar{},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
url := startFakeAPIServer(t, api)
|
||||
sc := &Scenario{
|
||||
Name: "collection_freshness_regression",
|
||||
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,
|
||||
ExpectedYYYYMMDD: []string{"20240527"},
|
||||
},
|
||||
Expect: Expect{
|
||||
Status: "ok",
|
||||
FreshnessStatus: "missing",
|
||||
MissingCount: intPtr(2),
|
||||
LatestYYYYMMDD: "20240527",
|
||||
GapStatus: "gap",
|
||||
GapCount: intPtr(2),
|
||||
DuplicateCount: intPtr(0),
|
||||
ProviderDelayDays: intPtr(0),
|
||||
},
|
||||
}},
|
||||
}
|
||||
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{
|
||||
"freshness_status=missing",
|
||||
"missing_count=2",
|
||||
"missing_symbols=000020,000660",
|
||||
"gap_status=gap",
|
||||
"gap_count=2",
|
||||
"latest_yyyymmdd=20240527",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output %q missing %q", out, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,6 +174,17 @@ var validFreshnessStatuses = map[string]bool{
|
|||
"error": true,
|
||||
}
|
||||
|
||||
// validGapStatuses defines the set of expected gap status values.
|
||||
var validGapStatuses = map[string]bool{
|
||||
"": true,
|
||||
"clean": true,
|
||||
"gap": true,
|
||||
"duplicate": true,
|
||||
"delayed": true,
|
||||
"mixed": true,
|
||||
"error": true,
|
||||
}
|
||||
|
||||
// validBacktestStatuses contains valid backtest run status values for the dry-run check.
|
||||
var validBacktestStatuses = map[string]bool{
|
||||
"": true,
|
||||
|
|
@ -254,6 +265,15 @@ type Expect struct {
|
|||
MissingCount *int `yaml:"missing_count"`
|
||||
// LatestYYYYMMDD expects a specific latest date.
|
||||
LatestYYYYMMDD string `yaml:"latest_yyyymmdd"`
|
||||
|
||||
// GapStatus expects a specific gap status ("clean", "gap", "duplicate", "delayed", "mixed", "error").
|
||||
GapStatus string `yaml:"gap_status"`
|
||||
// GapCount expects a specific number of gaps.
|
||||
GapCount *int `yaml:"gap_count"`
|
||||
// DuplicateCount expects a specific number of duplicate bars.
|
||||
DuplicateCount *int `yaml:"duplicate_count"`
|
||||
// ProviderDelayDays expects a specific provider delay in days.
|
||||
ProviderDelayDays *int `yaml:"provider_delay_days"`
|
||||
}
|
||||
|
||||
// Request carries the per-action parameters for market and backtest steps. Fields not
|
||||
|
|
@ -286,6 +306,9 @@ type Request struct {
|
|||
FromYYYYMMDD string `yaml:"from_yyyymmdd"`
|
||||
ToYYYYMMDD string `yaml:"to_yyyymmdd"`
|
||||
|
||||
// ExpectedYYYYMMDD is the list of expected trading dates.
|
||||
ExpectedYYYYMMDD []string `yaml:"expected_yyyymmdd"`
|
||||
|
||||
// StrategyID selects the strategy for starting a backtest.
|
||||
StrategyID string `yaml:"strategy_id"`
|
||||
// RunID identifies a specific backtest run.
|
||||
|
|
@ -537,6 +560,18 @@ func validateExpect(step Step) error {
|
|||
if step.Expect.MissingCount != nil && *step.Expect.MissingCount < 0 {
|
||||
return fmt.Errorf("step %q: expect.missing_count cannot be negative", step.ID)
|
||||
}
|
||||
if step.Expect.GapStatus != "" && !validGapStatuses[step.Expect.GapStatus] {
|
||||
return fmt.Errorf("step %q: unsupported expect.gap_status %q (want \"clean\", \"gap\", \"duplicate\", \"delayed\", \"mixed\", or \"error\")", step.ID, step.Expect.GapStatus)
|
||||
}
|
||||
if step.Expect.GapCount != nil && *step.Expect.GapCount < 0 {
|
||||
return fmt.Errorf("step %q: expect.gap_count cannot be negative", step.ID)
|
||||
}
|
||||
if step.Expect.DuplicateCount != nil && *step.Expect.DuplicateCount < 0 {
|
||||
return fmt.Errorf("step %q: expect.duplicate_count cannot be negative", step.ID)
|
||||
}
|
||||
if step.Expect.ProviderDelayDays != nil && *step.Expect.ProviderDelayDays < 0 {
|
||||
return fmt.Errorf("step %q: expect.provider_delay_days cannot be negative", step.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -558,6 +593,31 @@ func validateRequest(step Step) error {
|
|||
if step.Request.FromUnixMs > step.Request.ToUnixMs {
|
||||
return fmt.Errorf("step %q: request.from_unix_ms cannot be after request.to_unix_ms", step.ID)
|
||||
}
|
||||
if len(step.Request.ExpectedYYYYMMDD) > 0 {
|
||||
if step.Request.Timeframe != "daily" {
|
||||
return fmt.Errorf("step %q: expected_yyyymmdd is only supported for timeframe \"daily\"", step.ID)
|
||||
}
|
||||
var prevDate string
|
||||
for idx, d := range step.Request.ExpectedYYYYMMDD {
|
||||
t, err := time.ParseInLocation("20060102", d, time.UTC)
|
||||
if err != nil {
|
||||
return fmt.Errorf("step %q: expected_yyyymmdd[%d] %q must be in YYYYMMDD format", step.ID, idx, d)
|
||||
}
|
||||
if prevDate != "" {
|
||||
if d < prevDate {
|
||||
return fmt.Errorf("step %q: expected_yyyymmdd must be in ascending order", step.ID)
|
||||
}
|
||||
if d == prevDate {
|
||||
return fmt.Errorf("step %q: expected_yyyymmdd contains duplicate date %q", step.ID, d)
|
||||
}
|
||||
}
|
||||
prevDate = d
|
||||
tMs := t.UnixMilli()
|
||||
if tMs < step.Request.FromUnixMs || tMs > step.Request.ToUnixMs {
|
||||
return fmt.Errorf("step %q: expected_yyyymmdd[%d] %q is out of request window [%d, %d]", step.ID, idx, d, step.Request.FromUnixMs, step.Request.ToUnixMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
case ActionListInstruments:
|
||||
if !validMarkets[step.Request.Market] {
|
||||
return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market)
|
||||
|
|
|
|||
|
|
@ -976,8 +976,254 @@ steps:
|
|||
t.Fatalf("unexpected error on ParseScenario: %v", err)
|
||||
}
|
||||
|
||||
// Validate again to check idempotence
|
||||
if err := sc.Validate(); err != nil {
|
||||
t.Errorf("unexpected error on second Validate call: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnsupportedGapStatus(t *testing.T) {
|
||||
data := []byte("name: bad_gap_status\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n gap_status: invalid_status\n")
|
||||
_, err := ParseScenario(data)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown expect.gap_status, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "expect.gap_status") {
|
||||
t.Errorf("error = %v, want it to mention expect.gap_status", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsNegativeGapCounts(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
yaml string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "negative gap_count",
|
||||
yaml: "name: bad_gap_count\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n gap_count: -1\n",
|
||||
err: "expect.gap_count",
|
||||
},
|
||||
{
|
||||
name: "negative duplicate_count",
|
||||
yaml: "name: bad_duplicate_count\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n duplicate_count: -1\n",
|
||||
err: "expect.duplicate_count",
|
||||
},
|
||||
{
|
||||
name: "negative provider_delay_days",
|
||||
yaml: "name: bad_provider_delay\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n provider_delay_days: -1\n",
|
||||
err: "expect.provider_delay_days",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ParseScenario([]byte(tc.yaml))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %s, got nil", tc.name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.err) {
|
||||
t.Errorf("error = %v, want it to contain %q", err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExpectedYYYYMMDD(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
yaml string
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "timeframe not daily",
|
||||
yaml: `
|
||||
name: bad_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: minute_1
|
||||
from_unix_ms: 1716768000000
|
||||
to_unix_ms: 1716854400000
|
||||
expected_yyyymmdd: ["20240527"]
|
||||
`,
|
||||
expectedErr: "expected_yyyymmdd is only supported for timeframe \"daily\"",
|
||||
},
|
||||
{
|
||||
name: "malformed date",
|
||||
yaml: `
|
||||
name: malformed_date
|
||||
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: 1716768000000
|
||||
to_unix_ms: 1716854400000
|
||||
expected_yyyymmdd: ["2024-05-27"]
|
||||
`,
|
||||
expectedErr: "must be in YYYYMMDD format",
|
||||
},
|
||||
{
|
||||
name: "unsorted dates",
|
||||
yaml: `
|
||||
name: unsorted_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: 1716768000000
|
||||
to_unix_ms: 1716854400000
|
||||
expected_yyyymmdd: ["20240528", "20240527"]
|
||||
`,
|
||||
expectedErr: "must be in ascending order",
|
||||
},
|
||||
{
|
||||
name: "duplicate dates",
|
||||
yaml: `
|
||||
name: duplicate_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: 1716768000000
|
||||
to_unix_ms: 1716854400000
|
||||
expected_yyyymmdd: ["20240527", "20240527"]
|
||||
`,
|
||||
expectedErr: "contains duplicate date",
|
||||
},
|
||||
{
|
||||
name: "date out of window before",
|
||||
yaml: `
|
||||
name: out_of_window_before
|
||||
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: 1716768000000 # 2024-05-27 00:00:00 UTC
|
||||
to_unix_ms: 1716854400000 # 2024-05-28 00:00:00 UTC
|
||||
expected_yyyymmdd: ["20240526"]
|
||||
`,
|
||||
expectedErr: "is out of request window",
|
||||
},
|
||||
{
|
||||
name: "date out of window after",
|
||||
yaml: `
|
||||
name: out_of_window_after
|
||||
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: 1716768000000 # 2024-05-27 00:00:00 UTC
|
||||
to_unix_ms: 1716854400000 # 2024-05-28 00:00:00 UTC
|
||||
expected_yyyymmdd: ["20240529"]
|
||||
`,
|
||||
expectedErr: "is out of request window",
|
||||
},
|
||||
{
|
||||
name: "valid expected_yyyymmdd and gap expectations",
|
||||
yaml: `
|
||||
name: valid_gap_contract
|
||||
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: 1716768000000 # 2024-05-27 00:00:00 UTC
|
||||
to_unix_ms: 1716854400000 # 2024-05-28 00:00:00 UTC
|
||||
expected_yyyymmdd: ["20240527", "20240528"]
|
||||
expect:
|
||||
gap_status: clean
|
||||
gap_count: 0
|
||||
duplicate_count: 0
|
||||
provider_delay_days: 0
|
||||
`,
|
||||
expectedErr: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sc, err := ParseScenario([]byte(tt.yaml))
|
||||
if tt.expectedErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.expectedErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.expectedErr) {
|
||||
t.Errorf("error = %v, want it to contain %q", err, tt.expectedErr)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
step := sc.Steps[0]
|
||||
if len(step.Request.ExpectedYYYYMMDD) != 2 {
|
||||
t.Errorf("expected_yyyymmdd len = %d, want 2", len(step.Request.ExpectedYYYYMMDD))
|
||||
}
|
||||
if step.Expect.GapStatus != "clean" {
|
||||
t.Errorf("gap_status = %q, want clean", step.Expect.GapStatus)
|
||||
}
|
||||
if step.Expect.GapCount == nil || *step.Expect.GapCount != 0 {
|
||||
t.Errorf("gap_count = %v, want 0", step.Expect.GapCount)
|
||||
}
|
||||
if step.Expect.DuplicateCount == nil || *step.Expect.DuplicateCount != 0 {
|
||||
t.Errorf("duplicate_count = %v, want 0", step.Expect.DuplicateCount)
|
||||
}
|
||||
if step.Expect.ProviderDelayDays == nil || *step.Expect.ProviderDelayDays != 0 {
|
||||
t.Errorf("provider_delay_days = %v, want 0", step.Expect.ProviderDelayDays)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +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"}
|
||||
{"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"}
|
||||
{"action":"collection_freshness","duplicate_count":0,"freshness_status":"fresh","gap_count":0,"gap_status":"clean","latest_yyyymmdd":"20250501","missing_count":0,"provider_delay_days":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,5 +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"}
|
||||
{"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"}
|
||||
{"action":"collection_freshness","duplicate_count":0,"freshness_status":"fresh","gap_count":0,"gap_status":"clean","latest_yyyymmdd":"20240529","missing_count":0,"provider_delay_days":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`, `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` | 차트/바 시각화 레이아웃 미정 |
|
||||
| `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`, `gap_status`, `gap_count`, `duplicate_count`, `provider_delay_days` | `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`, `gap_status`, `gap_count`, `duplicate_count`, `provider_delay_days` | `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` | 차트/메트릭 레이아웃 미정 |
|
||||
|
|
|
|||
|
|
@ -51,8 +51,14 @@ steps:
|
|||
timeframe: daily
|
||||
from_unix_ms: 1746057600000
|
||||
to_unix_ms: 1747267200000
|
||||
expected_yyyymmdd: ["20250501"]
|
||||
expect:
|
||||
status: ok
|
||||
freshness_status: fresh
|
||||
missing_count: 0
|
||||
latest_yyyymmdd: "20250501"
|
||||
gap_status: clean
|
||||
gap_count: 0
|
||||
duplicate_count: 0
|
||||
provider_delay_days: 0
|
||||
|
||||
|
|
|
|||
|
|
@ -46,8 +46,14 @@ steps:
|
|||
timeframe: daily
|
||||
from_unix_ms: 1716854400000
|
||||
to_unix_ms: 1717027200000
|
||||
expected_yyyymmdd: ["20240529"]
|
||||
expect:
|
||||
status: ok
|
||||
freshness_status: fresh
|
||||
missing_count: 0
|
||||
latest_yyyymmdd: "20240529"
|
||||
gap_status: clean
|
||||
gap_count: 0
|
||||
duplicate_count: 0
|
||||
provider_delay_days: 0
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,12 @@ type Instrument struct {
|
|||
|
||||
type Provider string
|
||||
|
||||
const ProviderKIS Provider = "kis"
|
||||
const (
|
||||
ProviderKIS Provider = "kis"
|
||||
ProviderKRX Provider = "krx"
|
||||
ProviderDART Provider = "dart"
|
||||
ProviderYahoo Provider = "yahoo"
|
||||
)
|
||||
|
||||
// ProviderCapability declares the venues and timeframes a market data provider
|
||||
// can serve. It keeps provider distinct from market: one provider (KIS) can
|
||||
|
|
|
|||
|
|
@ -17,8 +17,19 @@ func TestUniverseSelectorVocabulary(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
if string(ProviderKIS) != "kis" {
|
||||
t.Errorf("ProviderKIS: got %q, want %q", string(ProviderKIS), "kis")
|
||||
providers := []struct {
|
||||
provider Provider
|
||||
want string
|
||||
}{
|
||||
{ProviderKIS, "kis"},
|
||||
{ProviderKRX, "krx"},
|
||||
{ProviderDART, "dart"},
|
||||
{ProviderYahoo, "yahoo"},
|
||||
}
|
||||
for _, c := range providers {
|
||||
if string(c.provider) != c.want {
|
||||
t.Errorf("provider %q: got %q, want %q", c.want, string(c.provider), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,10 +92,17 @@ func TestProviderCapabilityRequireDailyBars(t *testing.T) {
|
|||
}
|
||||
// A narrower KR-only provider that must reject US venues.
|
||||
krOnly := ProviderCapability{
|
||||
Provider: Provider("kiwoom"),
|
||||
Provider: ProviderKRX,
|
||||
Venues: map[Venue]bool{VenueKRX: true},
|
||||
Timeframes: map[Timeframe]bool{TimeframeDaily: true},
|
||||
}
|
||||
// A non-price-data provider can exist in the same provider vocabulary
|
||||
// without accidentally satisfying daily bar import capability.
|
||||
metadataOnly := ProviderCapability{
|
||||
Provider: ProviderDART,
|
||||
Venues: map[Venue]bool{VenueKRX: true},
|
||||
Timeframes: map[Timeframe]bool{},
|
||||
}
|
||||
|
||||
sel := func(market Market, venue Venue) UniverseSelector {
|
||||
return UniverseSelector{Kind: UniverseSelectorWatchlist, Market: market, Venue: venue, Symbols: []string{"X"}}
|
||||
|
|
@ -104,8 +122,9 @@ func TestProviderCapabilityRequireDailyBars(t *testing.T) {
|
|||
{"missing venue rejected", kis, ProviderKIS, sel(MarketUS, ""), true},
|
||||
{"unknown venue rejected", kis, ProviderKIS, sel(MarketUS, Venue("LSE")), true},
|
||||
{"market/venue mismatch rejected", kis, ProviderKIS, sel(MarketKR, VenueNASDAQ), true},
|
||||
{"kr-only capability rejects US", krOnly, Provider("kiwoom"), sel(MarketUS, VenueNASDAQ), true},
|
||||
{"kr-only capability allows KR", krOnly, Provider("kiwoom"), sel(MarketKR, VenueKRX), false},
|
||||
{"kr-only capability rejects US", krOnly, ProviderKRX, sel(MarketUS, VenueNASDAQ), true},
|
||||
{"kr-only capability allows KR", krOnly, ProviderKRX, sel(MarketKR, VenueKRX), false},
|
||||
{"metadata provider rejects daily bars", metadataOnly, ProviderDART, sel(MarketKR, VenueKRX), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
|
@ -124,3 +125,35 @@ func TestAnalysisResultColumnsAddedInVersionedMigration(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketDataSchemaKeepsBarsNormalized(t *testing.T) {
|
||||
// Backtest input bars must persist normalized OHLCV fields only. Provider raw
|
||||
// payloads can be decoded upstream, but the bars table/query contract must not
|
||||
// grow a default raw JSON payload column.
|
||||
backbone, err := migrationsFS.ReadFile("migrations/000001_worker_backbone.up.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read 000001 up migration: %v", err)
|
||||
}
|
||||
queries, err := os.ReadFile("queries/queries.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read queries.sql: %v", err)
|
||||
}
|
||||
schema := string(backbone)
|
||||
queryText := string(queries)
|
||||
for _, required := range []string{"instrument_id", "timeframe", "timestamp", "open", "high", "low", "close", "volume"} {
|
||||
if !strings.Contains(schema, required) {
|
||||
t.Errorf("bars schema missing normalized column %q", required)
|
||||
}
|
||||
if !strings.Contains(queryText, required) {
|
||||
t.Errorf("bars queries missing normalized column %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"raw_payload", "raw_response", "provider_payload"} {
|
||||
if strings.Contains(schema, forbidden) {
|
||||
t.Errorf("bars schema must not include raw provider payload column %q", forbidden)
|
||||
}
|
||||
if strings.Contains(queryText, forbidden) {
|
||||
t.Errorf("bars queries must not include raw provider payload column %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue