<!-- task=m-backtest-data-collection-infrastructure/01_watchlist_config plan=0 tag=CLI -->

# Plan - CLI Watchlist Config

## 이 파일을 읽는 구현 에이전트에게

`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 둔 채 리뷰 준비 상태로 보고한다. 종결, log rename, `complete.log`, archive 이동은 code-review 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 범위 충돌이 필요하면 review stub의 `사용자 리뷰 요청` 섹션에 근거를 기록하고 멈춘다. 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.

## 배경

`universe` Epic의 `daily-collect`는 기존 `import_daily_bars` 경계로 완료 처리되었다. 남은 첫 병목은 선택 종목군이 각 scenario step의 inline symbols로 흩어져 있어 등록/갱신 가능한 headless 설정 경계가 없다는 점이다. 이 작업은 CLI scenario YAML 안에 named universe 설정을 추가하고, import step이 그 설정을 참조해 동일한 provider/selector/market/venue/symbols를 재사용하게 만든다.

## 사용자 리뷰 요청 흐름

구현 중 사용자 전용 차단이 생기면 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 `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:
  - `watchlist-config`: 선택 종목군을 수집 대상으로 등록하고 갱신할 수 있는 headless 설정 경계를 만든다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-roadmap/current.md`
- `agent-roadmap/phase/backtest-loop/PHASE.md`
- `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/project/domain/operations/rules.md`
- `agent-ops/rules/project/domain/domain-model/rules.md`
- `agent-ops/rules/project/domain/worker/rules.md`
- `agent-ops/rules/project/domain/api/rules.md`
- `agent-ops/rules/project/domain/contracts/rules.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-test/local/api-smoke.md`
- `agent-test/local/contracts-smoke.md`
- `apps/cli/go.mod`
- `apps/cli/internal/operator/scenario.go`
- `apps/cli/internal/operator/scenario_test.go`
- `apps/cli/internal/operator/runner.go`
- `apps/cli/internal/operator/runner_market_test.go`
- `apps/cli/internal/operator/client.go`
- `apps/cli/internal/operator/client_test.go`
- `apps/cli/internal/operator/output.go`
- `apps/cli/internal/operator/parser_map.go`
- `apps/cli/internal/operator/parser_map_test.go`
- `apps/cli/internal/cli/cli.go`
- `apps/cli/internal/cli/cli_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/kis_daily_import_smoke.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/expected/kis_daily_import_smoke.jsonl`
- `apps/cli/testdata/operator/headless_validation.md`

### 테스트 환경 규칙

- `test_env=local`.
- `agent-test/local/rules.md`를 읽었다. 여러 도메인 후보 중 이 plan의 실제 변경은 `apps/cli/**`라 `operations-smoke`를 적용한다.
- 읽은 profile: `agent-test/local/operations-smoke.md`. 관련 경계 확인을 위해 `domain-model-smoke`, `worker-smoke`, `api-smoke`, `contracts-smoke`도 읽었지만 이 plan은 해당 source를 변경하지 않는다.
- 적용 명령: `go test ./apps/cli/internal/operator ./apps/cli/internal/cli`.
- `<확인 필요>` 값 없음. 새 외부 dependency 없음. `apps/cli/go.mod`에 `gopkg.in/yaml.v3`가 이미 있다.

### 테스트 커버리지 공백

- 기존 `scenario_test.go`는 inline `import_daily_bars` 필수 필드와 날짜 검증을 덮지만 named universe 참조를 덮지 않는다.
- 기존 `runner_market_test.go`는 inline request가 API로 전달되는지 덮지만 universe 참조가 materialized 되는지는 덮지 않는다.
- 이 plan에서 named universe parsing, duplicate name rejection, missing reference rejection, import request materialization 테스트를 추가한다.

### 심볼 참조

- renamed/removed symbol 없음. 새 struct/field/action helper만 추가한다.

### 분할 판단

- split 정책을 먼저 평가했다. 공유 task group은 `agent-task/m-backtest-data-collection-infrastructure/`다.
- `01_watchlist_config`: 독립 선행 작업. predecessor 없음.
- `02+01_freshness_status`: `01_watchlist_config`의 named universe 설정을 전제로 구현한다.
- 이 plan은 CLI scenario schema와 fixture 변경만 다루므로 단일 plan이 적절하다. worker/API/contracts 변경은 split gate에 해당하지만 이 plan 범위에서 제외한다.

### 범위 결정 근거

- protobuf schema와 generated contract는 변경하지 않는다. 기존 `ImportDailyBarsRequest` 필드에 맞춰 scenario request를 materialize한다.
- API/worker/importer는 변경하지 않는다. `daily-collect` 경계는 이미 완료 처리되었고, 이 작업은 headless 설정 경계만 만든다.
- Flutter 화면, provider live credential, scheduling loop는 범위 밖이다.

### 빌드 등급

- `local-G05`: CLI schema/fixture/test 변경으로 범위가 명확하고 검증이 빠르며, 새 storage/protocol 위험이 없다.

## 구현 체크리스트

- [ ] Scenario에 named universe 설정 타입과 `request.universe` 참조를 추가하고 validation/materialization을 구현한다.
- [ ] KR/US market data scenario fixture가 inline symbols 대신 named universe를 사용하도록 갱신하고 runner 테스트로 API 전달 값을 고정한다.
- [ ] `go test ./apps/cli/internal/operator ./apps/cli/internal/cli`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [CLI-1] Named Universe Schema

#### 문제

`apps/cli/internal/operator/scenario.go:241`의 `Request`는 import step마다 provider/selector/symbols를 직접 들고 있고, `apps/cli/internal/operator/scenario.go:330`의 `Scenario`에는 shared universe 설정 공간이 없다. `apps/cli/internal/operator/scenario.go:597`의 import validation도 inline fields만 허용한다.

```go
// apps/cli/internal/operator/scenario.go:241
type Request struct {
    // Market filters a list_instruments query ("", "kr", "us", "unspecified").
    Market string `yaml:"market"`
...
    // Symbols is the explicit list of tickers/symbols to import.
    Symbols []string `yaml:"symbols"`
```

```go
// apps/cli/internal/operator/scenario.go:330
type Scenario struct {
    Name    string   `yaml:"name"`
    Timeout Duration `yaml:"timeout"`
    Steps   []Step   `yaml:"steps"`
}
```

#### 해결 방법

`UniverseConfig`와 `Scenario.Universes`를 추가하고 `Request.Universe`를 추가한다. `Scenario.Validate`는 먼저 universe name 중복/필수 필드를 검증한 뒤, `import_daily_bars` step이 `request.universe`를 지정하면 inline request에 provider/selector_kind/market/venue/name/symbols를 materialize한다. 날짜 범위는 step별 실행 창이므로 request에 남긴다. inline 필드와 universe 필드가 충돌하면 validation error로 처리한다.

```go
type UniverseConfig struct {
    Name         string   `yaml:"name"`
    Provider     string   `yaml:"provider"`
    SelectorKind string   `yaml:"selector_kind"`
    Market       string   `yaml:"market"`
    Venue        string   `yaml:"venue"`
    Symbols      []string `yaml:"symbols"`
}

type Request struct {
    Universe string `yaml:"universe"`
    ...
}

type Scenario struct {
    Name      string           `yaml:"name"`
    Timeout   Duration         `yaml:"timeout"`
    Universes []UniverseConfig `yaml:"universes"`
    Steps     []Step           `yaml:"steps"`
}
```

`Validate` loop는 `for i := range s.Steps`로 바꾸고 `applyUniverseDefaults(&s.Steps[i], universeByName)`를 호출한 다음 기존 `validateRequest`를 재사용한다.

#### 수정 파일 및 체크리스트

- [ ] `apps/cli/internal/operator/scenario.go`: `UniverseConfig`, `Request.Universe`, universe index/validation/materialization helper 추가.
- [ ] `apps/cli/internal/operator/scenario_test.go`: valid universe import, duplicate universe name, missing universe reference, inline conflict, materialized request assertions 추가.

#### 테스트 작성

- `TestValidateImportDailyBarsUniverseReference`: named universe를 참조한 import step이 provider/selector/market/venue/symbols를 채우는지 확인.
- `TestValidateRejectsDuplicateUniverseName`: 같은 name 두 번 선언 시 실패.
- `TestValidateRejectsMissingUniverseReference`: 없는 universe 참조 시 실패.
- `TestValidateRejectsUniverseInlineConflict`: universe 참조와 inline symbols/provider 등이 같이 있으면 실패.

#### 중간 검증

```bash
go test ./apps/cli/internal/operator
```

기대 결과: exit code 0.

### [CLI-2] Scenario Fixtures Use Named Universes

#### 문제

대표 market data fixtures는 모두 inline watchlist를 반복한다. 예: `apps/cli/testdata/operator/market_data_status_query.yaml:17`의 import step은 provider/selector/symbols를 직접 선언한다.

```yaml
# apps/cli/testdata/operator/market_data_status_query.yaml:17
  - id: import_kr_daily_bars
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      market: kr
      venue: krx
      symbols: ["005930"]
```

#### 해결 방법

KR/US fixtures에 top-level `universes`를 추가하고 import step은 `request.universe`를 참조하게 바꾼다. runner output은 기존과 동일해야 한다. `runner_market_test.go`의 existing fake API assertions를 유지하면서 loaded scenario가 materialized request를 보내는지 확인한다.

```yaml
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    market: kr
    venue: krx
    symbols: ["005930"]
steps:
  - id: import_kr_daily_bars
    action: import_daily_bars
    request:
      universe: kr-core
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
```

#### 수정 파일 및 체크리스트

- [ ] `apps/cli/testdata/operator/market_data_status_query.yaml`: KR universe 추가.
- [ ] `apps/cli/testdata/operator/us_market_data_status_query.yaml`: US universe 추가.
- [ ] `apps/cli/testdata/operator/kis_daily_import_smoke.yaml`: smoke universe 추가.
- [ ] `apps/cli/internal/operator/runner_market_test.go`: loaded fixture가 기존 API request와 동일한 provider/venue/symbols를 보내는지 유지/보강.
- [ ] `apps/cli/testdata/operator/expected/*.jsonl`: output이 바뀌지 않아야 하며 변경이 필요하면 이유를 review에 기록.

#### 테스트 작성

- 기존 `TestRunScenarioMarketDataStatusImportsBeforeReads`를 유지하고, materialized universe request가 `kis`, `20240527..20240528`, expected symbols를 보내는지 확인한다.

#### 중간 검증

```bash
go test ./apps/cli/internal/operator
```

기대 결과: exit code 0.

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `apps/cli/internal/operator/scenario.go` | CLI-1 |
| `apps/cli/internal/operator/scenario_test.go` | CLI-1 |
| `apps/cli/internal/operator/runner_market_test.go` | CLI-2 |
| `apps/cli/testdata/operator/market_data_status_query.yaml` | CLI-2 |
| `apps/cli/testdata/operator/us_market_data_status_query.yaml` | CLI-2 |
| `apps/cli/testdata/operator/kis_daily_import_smoke.yaml` | CLI-2 |
| `apps/cli/testdata/operator/expected/market_data_status_query.jsonl` | CLI-2 |
| `apps/cli/testdata/operator/expected/us_market_data_status_query.jsonl` | CLI-2 |
| `apps/cli/testdata/operator/expected/kis_daily_import_smoke.jsonl` | CLI-2 |

## 최종 검증

```bash
go test ./apps/cli/internal/operator ./apps/cli/internal/cli
```

기대 결과: exit code 0. Go test cache output은 허용한다. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
