fix(domain): capability matrix API 구현 누락 항목을 보완한다

ProviderCapability에 Rules 필드 추가
CheckBars, ProviderCapabilityRequest, ProviderCapabilityDecision, ProviderCapabilityRule 타입 추가
types_test.go에 TestProviderCapabilityMatrix 테스트 추가
CODE_REVIEW-local-G05.md 구현 체크리스트 및 검증 결과 작성
This commit is contained in:
toki 2026-06-17 22:42:35 +09:00
parent 97373ca14e
commit 012935e906
3 changed files with 244 additions and 11 deletions

View file

@ -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 검증 결과와 동일 — 모든 패키지 통과)
```
---

View file

@ -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)

View file

@ -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)
}
})
}
}