diff --git a/agent-task/m-backtest-multi-timeframe-coverage/01_domain_capability_matrix/CODE_REVIEW-local-G05.md b/agent-task/m-backtest-multi-timeframe-coverage/01_domain_capability_matrix/CODE_REVIEW-local-G05.md index 8b0f073..5c105ce 100644 --- a/agent-task/m-backtest-multi-timeframe-coverage/01_domain_capability_matrix/CODE_REVIEW-local-G05.md +++ b/agent-task/m-backtest-multi-timeframe-coverage/01_domain_capability_matrix/CODE_REVIEW-local-G05.md @@ -35,15 +35,15 @@ task=m-backtest-multi-timeframe-coverage/01_domain_capability_matrix, plan=0, ta | 항목 | 완료 여부 | |------|---------| -| [API-1] Domain capability matrix API | [ ] | -| [API-2] Compatibility compile guard | [ ] | +| [API-1] Domain capability matrix API | [x] | +| [API-2] Compatibility compile guard | [x] | ## 구현 체크리스트 -- [ ] `ProviderCapability`를 provider/market/venue/asset type/timeframe matrix request와 accepted/rejected decision을 표현하도록 확장하되, 후속 rollout 전 기존 daily call site가 컴파일될 수 있는 호환 wrapper를 유지한다. -- [ ] domain tests에 KR/US, equity/ETF, monthly/daily/minute_1/minute_5 accepted/rejected와 rejection reason case를 추가한다. -- [ ] `go test ./packages/domain/...`와 `go test ./services/worker/...`를 실행해 domain behavior와 기존 worker call-site compile을 확인한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. +- [x] `ProviderCapability`를 provider/market/venue/asset type/timeframe matrix request와 accepted/rejected decision을 표현하도록 확장하되, 후속 rollout 전 기존 daily call site가 컴파일될 수 있는 호환 wrapper를 유지한다. +- [x] domain tests에 KR/US, equity/ETF, monthly/daily/minute_1/minute_5 accepted/rejected와 rejection reason case를 추가한다. +- [x] `go test ./packages/domain/...`와 `go test ./services/worker/...`를 실행해 domain behavior와 기존 worker call-site compile을 확인한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. ## 코드리뷰 전용 체크리스트 @@ -65,11 +65,14 @@ task=m-backtest-multi-timeframe-coverage/01_domain_capability_matrix, plan=0, ta ## 계획 대비 변경 사항 -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ +- PLAN에서는 `ProviderCapability` struct을 `Rules []ProviderCapabilityRule`로 완전히 교체하도록 명시했지만, 기존 worker call site(`RequireDailyBars`)의 컴파일을 유지하기 위해 기존 `Venues`/`Timeframes` 필드와 `SupportsDailyBars` 메서드를 deprecated 호환 필드로 유지했다. +- PLAN의 After 예시에서는 `ProviderCapability`에 `Venues`/`Timeframes` 필드가 없었지만, 실제 구현에서는 후속 rollout 전 호환성을 위해 함께 두었다. ## 주요 설계 결정 -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ +- `CheckBars`는 `ProviderCapabilityRule` slice를 순회하며 첫 번째 일치하는 rule의 timeframe 지원 여부를 판정 기준으로 사용한다. rule이 하나도 없거나 timeframe이 false이면 rejected. +- `RequireDailyBars`는 기존 call site가 깨지지 않도록 deprecated 메서드로 유지하고, 새 matrix API(`CheckBars`)를 호출하지 않는다. +- `ProviderCapabilityRule.Timeframes`는 `map[Timeframe]bool`로, 허용/불허를 명시적으로 설정한다. (false 기본값을 사용하여 미지원도 명시 가능) ## 사용자 리뷰 요청 @@ -103,19 +106,34 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 ### API-1 중간 검증 ```bash $ go test ./packages/domain/... -(output) +ok git.toki-labs.com/toki/alt/packages/domain/backtest (cached) +ok git.toki-labs.com/toki/alt/packages/domain/market 0.003s +ok git.toki-labs.com/toki/alt/packages/domain/trading (cached) ``` ### API-2 중간 검증 ```bash $ go test ./services/worker/... -(output) +ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.012s +ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-migrate 0.007s +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.008s +ok git.toki-labs.com/toki/alt/services/worker/internal/config (cached) +ok git.toki-labs.com/toki/alt/services/worker/internal/contracts (cached) +ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.006s +ok git.toki-labs.com/toki/alt/services/worker/internal/livetrading 0.015s +ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.009s +ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.009s +ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.009s +ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.025s +ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys (cached) +ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.067s +ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.155s ``` ### 최종 검증 ```bash $ go test ./packages/domain/... ./services/worker/... -(output) +(위 API-1, API-2 검증 결과와 동일 — 모든 패키지 통과) ``` --- diff --git a/packages/domain/market/types.go b/packages/domain/market/types.go index 1367496..dae4fd4 100644 --- a/packages/domain/market/types.go +++ b/packages/domain/market/types.go @@ -104,10 +104,12 @@ const ( // can serve. It keeps provider distinct from market: one provider (KIS) can // declare several venues across multiple markets, while a narrower future // provider can declare KR-only coverage without changing the import boundary. +// Rules holds the provider/market/venue/asset type/timeframe capability matrix. type ProviderCapability struct { Provider Provider Venues map[Venue]bool Timeframes map[Timeframe]bool + Rules []ProviderCapabilityRule } // SupportsDailyBars reports whether the capability covers daily bars for venue. @@ -115,11 +117,104 @@ func (c ProviderCapability) SupportsDailyBars(venue Venue) bool { return c.Venues[venue] && c.Timeframes[TimeframeDaily] } +// ProviderCapabilityRule defines a single row in the capability matrix. +// Each rule matches a market/venue combination, a set of allowed asset types +// and a set of allowed timeframes. +type ProviderCapabilityRule struct { + Market Market + Venue Venue + AssetTypes map[AssetType]bool + Timeframes map[Timeframe]bool +} + +// ProviderCapabilityRequest is a call-site request that describes what +// provider/market/venue/asset type/timeframe combination the caller needs. +type ProviderCapabilityRequest struct { + Provider Provider + Market Market + Venue Venue + AssetType AssetType + Timeframe Timeframe +} + +// ProviderCapabilityDecision holds the result of a capability check. +type ProviderCapabilityDecision struct { + Accepted bool + Reason string +} + +// CheckBars evaluates the full provider/market/venue/asset type/timeframe +// capability matrix and returns whether the request is accepted or rejected +// with a stable reason string. +func (c ProviderCapability) CheckBars(req ProviderCapabilityRequest) ProviderCapabilityDecision { + if req.Provider != c.Provider { + return ProviderCapabilityDecision{ + Accepted: false, + Reason: fmt.Sprintf("provider %q does not match capability provider %q", req.Provider, c.Provider), + } + } + if !c.Venues[req.Venue] { + return ProviderCapabilityDecision{ + Accepted: false, + Reason: fmt.Sprintf("provider %q does not support venue %q", req.Provider, req.Venue), + } + } + meta, ok := GetVenueMetadata(req.Venue) + if !ok { + return ProviderCapabilityDecision{ + Accepted: false, + Reason: fmt.Sprintf("provider %q does not support venue %q", req.Provider, req.Venue), + } + } + if req.Market != "" && req.Market != meta.Market { + return ProviderCapabilityDecision{ + Accepted: false, + Reason: fmt.Sprintf("market %q does not match venue %q market %q", req.Market, req.Venue, meta.Market), + } + } + if len(c.Rules) == 0 { + return ProviderCapabilityDecision{ + Accepted: false, + Reason: fmt.Sprintf("no capability rules defined for provider %q", c.Provider), + } + } + var matched bool + for _, rule := range c.Rules { + if rule.Market != req.Market || rule.Venue != req.Venue { + continue + } + if !rule.AssetTypes[req.AssetType] { + continue + } + if !rule.Timeframes[req.Timeframe] { + return ProviderCapabilityDecision{ + Accepted: false, + Reason: fmt.Sprintf("provider %q does not support asset type %q timeframe %q on venue %q", + req.Provider, req.AssetType, req.Timeframe, req.Venue), + } + } + matched = true + break + } + if !matched { + return ProviderCapabilityDecision{ + Accepted: false, + Reason: fmt.Sprintf("no capability rule matches market %q venue %q asset type %q timeframe %q", req.Market, req.Venue, req.AssetType, req.Timeframe), + } + } + return ProviderCapabilityDecision{ + Accepted: true, + Reason: "", + } +} + // RequireDailyBars is the pre-import gate that rejects unsupported // provider/market/venue combinations before any fetch. It validates that the // request provider matches this capability, that the selector pins a venue this // capability supports, that the selector market (when set) agrees with the // venue's market, and that the capability covers daily bars for that venue. +// Deprecated: use CheckBars instead. This wrapper is kept for backward +// compatibility with existing call sites until full rollout. func (c ProviderCapability) RequireDailyBars(provider Provider, selector UniverseSelector) error { if provider != c.Provider { return fmt.Errorf("provider %q does not match capability provider %q", provider, c.Provider) diff --git a/packages/domain/market/types_test.go b/packages/domain/market/types_test.go index fce1d76..493b3e6 100644 --- a/packages/domain/market/types_test.go +++ b/packages/domain/market/types_test.go @@ -164,3 +164,123 @@ func TestAssetTypeVocabulary(t *testing.T) { t.Errorf("AssetTypeETF: got %q, want %q", string(AssetTypeETF), "etf") } } + +func TestProviderCapabilityMatrix(t *testing.T) { + // KIS capability with explicit rules for KR/US equity/ETF daily/monthly/minute. + kis := ProviderCapability{ + Provider: ProviderKIS, + Venues: map[Venue]bool{ + VenueKRX: true, + VenueNASDAQ: true, + VenueNYSE: true, + }, + Timeframes: map[Timeframe]bool{ + TimeframeDaily: true, + TimeframeMonthly: true, + TimeframeMin1: true, + TimeframeMin5: true, + }, + Rules: []ProviderCapabilityRule{ + { + Market: MarketKR, + Venue: VenueKRX, + AssetTypes: map[AssetType]bool{ + AssetTypeEquity: true, + AssetTypeETF: true, + }, + Timeframes: map[Timeframe]bool{ + TimeframeDaily: true, + TimeframeMonthly: false, + TimeframeMin1: false, + TimeframeMin5: false, + }, + }, + { + Market: MarketUS, + Venue: VenueNASDAQ, + AssetTypes: map[AssetType]bool{ + AssetTypeEquity: true, + AssetTypeETF: true, + }, + Timeframes: map[Timeframe]bool{ + TimeframeDaily: true, + TimeframeMonthly: false, + TimeframeMin1: false, + TimeframeMin5: false, + }, + }, + }, + } + + cases := []struct { + name string + req ProviderCapabilityRequest + wantAccepted bool + wantReason string + }{ + // Accepted cases + {"KIS KR KRX equity daily", ProviderCapabilityRequest{ + Provider: ProviderKIS, Market: MarketKR, Venue: VenueKRX, + AssetType: AssetTypeEquity, Timeframe: TimeframeDaily, + }, true, ""}, + {"KIS KR KRX etf daily", ProviderCapabilityRequest{ + Provider: ProviderKIS, Market: MarketKR, Venue: VenueKRX, + AssetType: AssetTypeETF, Timeframe: TimeframeDaily, + }, true, ""}, + {"KIS US NASDAQ equity daily", ProviderCapabilityRequest{ + Provider: ProviderKIS, Market: MarketUS, Venue: VenueNASDAQ, + AssetType: AssetTypeEquity, Timeframe: TimeframeDaily, + }, true, ""}, + {"KIS US NASDAQ etf daily", ProviderCapabilityRequest{ + Provider: ProviderKIS, Market: MarketUS, Venue: VenueNASDAQ, + AssetType: AssetTypeETF, Timeframe: TimeframeDaily, + }, true, ""}, + // Rejected: monthly + {"KIS KR KRX equity monthly rejected", ProviderCapabilityRequest{ + Provider: ProviderKIS, Market: MarketKR, Venue: VenueKRX, + AssetType: AssetTypeEquity, Timeframe: TimeframeMonthly, + }, false, "provider \"kis\" does not support asset type \"equity\" timeframe \"1mo\" on venue \"KRX\""}, + // Rejected: minute_1 + {"KIS KR KRX equity minute_1 rejected", ProviderCapabilityRequest{ + Provider: ProviderKIS, Market: MarketKR, Venue: VenueKRX, + AssetType: AssetTypeEquity, Timeframe: TimeframeMin1, + }, false, "provider \"kis\" does not support asset type \"equity\" timeframe \"1m\" on venue \"KRX\""}, + // Rejected: minute_5 + {"KIS KR KRX equity minute_5 rejected", ProviderCapabilityRequest{ + Provider: ProviderKIS, Market: MarketKR, Venue: VenueKRX, + AssetType: AssetTypeEquity, Timeframe: TimeframeMin5, + }, false, "provider \"kis\" does not support asset type \"equity\" timeframe \"5m\" on venue \"KRX\""}, + // Rejected: provider mismatch + {"provider mismatch rejected", ProviderCapabilityRequest{ + Provider: ProviderYahoo, Market: MarketUS, Venue: VenueNASDAQ, + AssetType: AssetTypeEquity, Timeframe: TimeframeDaily, + }, false, "provider \"yahoo\" does not match capability provider \"kis\""}, + // Rejected: unknown venue + {"unknown venue rejected", ProviderCapabilityRequest{ + Provider: ProviderKIS, Market: MarketUS, Venue: Venue("LSE"), + AssetType: AssetTypeEquity, Timeframe: TimeframeDaily, + }, false, "provider \"kis\" does not support venue \"LSE\""}, + // Rejected: market/venue mismatch + {"market/venue mismatch rejected", ProviderCapabilityRequest{ + Provider: ProviderKIS, Market: MarketKR, Venue: VenueNASDAQ, + AssetType: AssetTypeEquity, Timeframe: TimeframeDaily, + }, false, "market \"KR\" does not match venue \"NASDAQ\" market \"US\""}, + // Rejected: no matching rule (asset type not in rule) + {"no matching rule", ProviderCapabilityRequest{ + Provider: ProviderKIS, Market: MarketUS, Venue: VenueNYSE, + AssetType: AssetTypeEquity, Timeframe: TimeframeDaily, + }, false, "no capability rule matches market \"US\" venue \"NYSE\" asset type \"equity\" timeframe \"1d\""}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dec := kis.CheckBars(c.req) + if dec.Accepted != c.wantAccepted { + t.Errorf("CheckBars(%q) accepted: got %v, want %v", c.name, dec.Accepted, c.wantAccepted) + } + if c.wantReason != "" && dec.Reason != c.wantReason { + t.Errorf("CheckBars(%q) reason: got %q, want %q", c.name, dec.Reason, c.wantReason) + } + }) + } +}