<!-- task=m-backtest-data-collection-infrastructure/02+01_storage_bar_selector plan=1 tag=REVIEW_REFACTOR -->
# PLAN-cloud-G05: Review Follow-up - Storage Bar Selector

## 이 파일을 읽는 구현 에이전트에게
이 계획은 `code_review_cloud_G05_0.log`의 FAIL Required 이슈만 수정한다. 구현 후 반드시 검증을 실행하고 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션에 실제 변경 내용과 stdout/stderr를 채운 뒤 active 파일을 유지한 채 리뷰 준비를 보고한다. 최종 PASS, log rename, `complete.log`, archive 이동은 code-review skill 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret, 또는 범위 충돌 때문에 멈춰야 하면 사용자에게 직접 묻지 말고 review stub의 `사용자 리뷰 요청`에 증거를 채운 뒤 중단한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 공백은 사용자 리뷰 요청 사유가 아니다.

## 배경
첫 리뷰에서 storage selector 구현은 대부분 계획을 충족했지만, whitespace-only selector entry가 full-market fallback으로 바뀌는 correctness 결함이 발견됐다. 또한 `StorageBarSource`의 timestamp+instrument id 정렬 계약이 engine의 timestamp-only sort에서 보장되지 않는다. 이 follow-up은 두 Required 이슈만 닫고 기존 job payload selector 후속 범위는 건드리지 않는다.

## 사용자 리뷰 요청 흐름
구현 중 사용자 전용 결정이나 범위 충돌이 생기면 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식 그대로 기록한다. 구현 에이전트는 채팅으로 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. code-review가 사용자 리뷰 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `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/worker-smoke.md`
- `agent-test/local/domain-model-smoke.md`
- `agent-ops/rules/project/domain/worker/rules.md`
- `agent-ops/rules/project/domain/domain-model/rules.md`
- `agent-task/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/plan_cloud_G05_0.log`
- `agent-task/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/code_review_cloud_G05_0.log`
- `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/01_input_selector_contract/complete.log`
- `packages/domain/backtest/types.go`
- `packages/domain/market/types.go`
- `services/worker/internal/storage/ports.go`
- `services/worker/internal/backtest/bar_source.go`
- `services/worker/internal/backtest/bar_source_test.go`
- `services/worker/internal/backtest/engine.go`
- `services/worker/internal/backtest/engine_test.go`
- `services/worker/internal/backtest/fixture_test.go`
- `services/worker/internal/backtest/flow_test.go`
- `services/worker/internal/backtest/strategies.go`
- `services/worker/internal/jobs/backtest_jobs.go`
- `services/worker/internal/jobs/backtest_jobs_test.go`
- `services/worker/internal/jobs/backtest_starter.go`
- `services/worker/internal/papertrading/engine.go`
- `services/worker/cmd/alt-worker/main.go`

### 테스트 환경 규칙
- `test_env=local`로 판단했다.
- `agent-test/local/rules.md`가 존재해 읽었다.
- 매칭 profile로 `agent-test/local/worker-smoke.md`, `agent-test/local/domain-model-smoke.md`를 읽었다.
- worker/backtest 변경이므로 `go test -count=1 ./services/worker/...`를 worker smoke로 사용한다.
- 기존 plan의 final command와 domain smoke를 유지하기 위해 `go test -count=1 ./packages/domain/... ./services/worker/internal/backtest ./services/worker/internal/jobs`도 실행한다.
- Go test cache 출력은 허용하지 않는다.

### 테스트 커버리지 공백
- whitespace-only `InstrumentIDs` 또는 `Symbols` selector entry가 error로 처리되는 회귀 테스트가 없다.
- 같은 timestamp의 복수 instrument bars가 engine에서 instrument id tie-breaker 순서로 strategy에 전달되는 테스트가 없다.

### 심볼 참조
- renamed/removed symbol: backtest package `BarSource.GetBars`가 engine-facing `GetBarsForRun`으로 바뀌었다.
- 확인한 call sites: `services/worker/internal/backtest/engine.go`, `services/worker/internal/backtest/engine_test.go`, `services/worker/internal/backtest/fixture_test.go`, `services/worker/internal/backtest/flow_test.go`, `services/worker/cmd/alt-worker/main.go`.
- compatibility call sites: `services/worker/internal/papertrading/engine.go`는 `GetBars`를 계속 사용하고, `StorageBarSource.GetBars`는 유지되어 있다.

### 분할 판단
- 기존 shared task group은 `agent-task/m-backtest-data-collection-infrastructure/`다.
- 이 follow-up은 기존 split subtask `02+01_storage_bar_selector` 안에서 plan=1로 이어간다.
- predecessor `01_input_selector_contract`는 `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/01_input_selector_contract/complete.log`로 PASS 완료가 확인됐다.
- sibling `03+01,02_backtest_selector_flow`는 이 subtask PASS 후 진행한다. 이 follow-up은 새 subtask로 분리하지 않는다.

### 범위 결정 근거
- `RunBacktestPayload` selector 전달, starter/job rail selector 보존, CLI scenario handoff는 후속 `03+01,02_backtest_selector_flow` 범위라 수정하지 않는다.
- proto/domain selector shape는 선행 `01_input_selector_contract` 범위라 수정하지 않는다.
- `agent-roadmap/**` 문서는 이 follow-up 구현 범위가 아니다. PASS 완료 이벤트와 roadmap 갱신은 runtime/code-review 이후 경계가 맡는다.

### 빌드 등급
- build lane: `cloud-G05`. storage-backed backtest selector correctness와 engine order determinism을 함께 닫는 bounded follow-up이며, 기존 cloud route를 유지한다.

## 의존 관계 및 구현 순서
1. whitespace-only selector entry를 error로 처리하고 storage source regression test를 추가한다.
2. engine sort comparator에 instrument id tie-breaker를 추가하고 engine regression test를 추가한다.
3. 지정 검증을 fresh run으로 실행한다.

## 구현 체크리스트
- [ ] whitespace-only selector entry가 full-market fallback으로 바뀌지 않고 error를 반환하도록 수정하고 instrument id/symbol regression tests를 추가한다.
- [ ] engine의 same-timestamp bar 정렬이 instrument id tie-breaker를 보장하도록 수정하고 regression test를 추가한다.
- [ ] 지정 `go test -count=1` 검증을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_REFACTOR-1] Whitespace Selector Error
#### 문제
`services/worker/internal/backtest/bar_source.go:103`에서 selector entry를 trim한 뒤 empty 값은 버린다.

```go
// services/worker/internal/backtest/bar_source.go:103
wantIDs := make([]string, 0, len(selector.InstrumentIDs))
for _, id := range selector.InstrumentIDs {
	if trimmed := strings.TrimSpace(string(id)); trimmed != "" {
		wantIDs = append(wantIDs, trimmed)
	}
}
```

이후 `services/worker/internal/backtest/bar_source.go:116`에서 `wantIDs`와 `wantSymbols`가 모두 비면 full-market fallback을 반환한다. raw selector 항목이 whitespace-only였던 invalid input도 전체 시장 실행으로 확대될 수 있다.

#### 해결 방법
raw selector slice에 entry가 있는데 trim 결과가 empty이면 해당 entry를 unresolved selector error로 처리한다. empty selector fallback은 raw selector가 실제로 비어 있거나, 유효한 entry가 전혀 요청되지 않은 명시적 empty selector에만 허용한다.

예시 방향:

```go
for _, id := range selector.InstrumentIDs {
	trimmed := strings.TrimSpace(string(id))
	if trimmed == "" {
		return nil, fmt.Errorf("backtest storage bar source: selector instrument id is empty")
	}
	wantIDs = append(wantIDs, trimmed)
}
```

symbol도 같은 방식으로 처리한다. error prefix는 기존 `backtest storage bar source:`를 유지한다.

#### 수정 파일 및 체크리스트
- [ ] `services/worker/internal/backtest/bar_source.go`에서 whitespace-only instrument id selector를 error로 반환한다.
- [ ] `services/worker/internal/backtest/bar_source.go`에서 whitespace-only symbol selector를 error로 반환한다.
- [ ] 기존 empty selector fallback은 selector slice가 실제로 비어 있는 경우 유지한다.

#### 테스트 작성
- `services/worker/internal/backtest/bar_source_test.go`에 `TestStorageBarSourceRejectsWhitespaceOnlySelector`를 추가한다.
- assertion: `InstrumentIDs: []market.InstrumentID{" "}`와 `Symbols: []string{" "}` 모두 error를 반환한다.
- 기존 `TestStorageBarSourceEmptySelectorReadsMarket`는 계속 통과해야 한다.

#### 중간 검증
```bash
go test -count=1 ./services/worker/internal/backtest
```
예상 결과: backtest package tests exit 0.

### [REVIEW_REFACTOR-2] Engine Same-Timestamp Tie-Breaker
#### 문제
`StorageBarSource.collectBars`는 `services/worker/internal/backtest/bar_source.go:82`에서 timestamp가 같을 때 instrument id로 tie-break한다. 하지만 `services/worker/internal/backtest/engine.go:53`이 bars를 timestamp만으로 다시 정렬한다.

```go
// services/worker/internal/backtest/engine.go:53
sort.Slice(bars, func(i, j int) bool {
	return bars[i].Timestamp.Before(bars[j].Timestamp)
})
```

같은 날짜의 여러 instrument bar가 들어오면 strategy 처리 순서가 engine 경계에서 명확히 보장되지 않는다.

#### 해결 방법
engine sort comparator도 `StorageBarSource`와 `papertrading.Engine`처럼 timestamp가 같을 때 `InstrumentID`를 tie-breaker로 사용한다.

```go
sort.Slice(bars, func(i, j int) bool {
	if bars[i].Timestamp.Equal(bars[j].Timestamp) {
		return bars[i].InstrumentID < bars[j].InstrumentID
	}
	return bars[i].Timestamp.Before(bars[j].Timestamp)
})
```

#### 수정 파일 및 체크리스트
- [ ] `services/worker/internal/backtest/engine.go`의 sort comparator에 instrument id tie-breaker를 추가한다.
- [ ] 기존 chronological sorting behavior는 유지한다.

#### 테스트 작성
- `services/worker/internal/backtest/engine_test.go`에 `TestEngineOrdersEqualTimestampBarsByInstrumentID`를 추가하거나 기존 engine test를 확장한다.
- fixture: 같은 timestamp의 `KRX:005930`, `KRX:000660` bars를 역순으로 source에 넣는다.
- assertion: strategy `Decide` 호출 순서가 instrument id 오름차순이다.

#### 중간 검증
```bash
go test -count=1 ./services/worker/internal/backtest
```
예상 결과: engine ordering regression test 포함 exit 0.

## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/worker/internal/backtest/bar_source.go` | REVIEW_REFACTOR-1 |
| `services/worker/internal/backtest/bar_source_test.go` | REVIEW_REFACTOR-1 |
| `services/worker/internal/backtest/engine.go` | REVIEW_REFACTOR-2 |
| `services/worker/internal/backtest/engine_test.go` | REVIEW_REFACTOR-2 |

## 최종 검증
```bash
go test -count=1 ./packages/domain/... ./services/worker/internal/backtest ./services/worker/internal/jobs
go test -count=1 ./services/worker/...
```
예상 결과: 모든 package exit 0. Go test cache 출력은 허용하지 않는다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
