- Archive G06 minute import rejection task files (plan, code review, complete) - Add monthly aggregation module (services/worker/internal/marketdata/aggregation/) - Fix runner and scenario handling for rejected minute imports - Update tests and testdata for minute import rejection flow - Update G05 code review for monthly aggregation
160 lines
5.9 KiB
Go
160 lines
5.9 KiB
Go
package jobs
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
|
|
)
|
|
|
|
// importDateLayout matches the KIS YYYYMMDD date fields the payload date range
|
|
// uses (see the KIS daily chart request fixture).
|
|
const importDateLayout = "20060102"
|
|
|
|
// DailyBarImporter is the import surface a daily bar job dispatches to. The
|
|
// jobs package depends only on this interface so the concrete importer (and its
|
|
// provider/storage wiring) stays out of the job boundary.
|
|
type DailyBarImporter interface {
|
|
ImportDailyBars(ctx context.Context, request importer.DailyBarRequest) (importer.Result, error)
|
|
}
|
|
|
|
// DailyBarImportPayload is the decoded KindImportDailyBars job payload. It
|
|
// carries the provider and the universe selector (kind, venue/market/name, and
|
|
// symbols) plus an optional date range the provider applies when fetching.
|
|
// Timeframe is optional: empty defaults to "daily"; non-daily values are
|
|
// rejected as a typed error before reaching the importer.
|
|
type DailyBarImportPayload struct {
|
|
Provider string `json:"provider"`
|
|
SelectorKind string `json:"selector_kind"`
|
|
Market string `json:"market"`
|
|
Venue string `json:"venue"`
|
|
Name string `json:"name"`
|
|
Symbols []string `json:"symbols"`
|
|
From string `json:"from,omitempty"`
|
|
To string `json:"to,omitempty"`
|
|
Timeframe string `json:"timeframe,omitempty"`
|
|
}
|
|
|
|
// selector builds the domain universe selector this payload describes.
|
|
func (p DailyBarImportPayload) selector() market.UniverseSelector {
|
|
return market.UniverseSelector{
|
|
Kind: market.UniverseSelectorKind(p.SelectorKind),
|
|
Market: market.Market(p.Market),
|
|
Venue: market.Venue(p.Venue),
|
|
Symbols: p.Symbols,
|
|
Name: p.Name,
|
|
}
|
|
}
|
|
|
|
// request resolves the payload into an importer request, parsing the optional
|
|
// YYYYMMDD date range so the provider/date-range contract reaches the importer.
|
|
func (p DailyBarImportPayload) request() (importer.DailyBarRequest, error) {
|
|
req := importer.DailyBarRequest{
|
|
Provider: market.Provider(p.Provider),
|
|
Selector: p.selector(),
|
|
}
|
|
from, err := parseImportDate("from", p.From)
|
|
if err != nil {
|
|
return importer.DailyBarRequest{}, err
|
|
}
|
|
to, err := parseImportDate("to", p.To)
|
|
if err != nil {
|
|
return importer.DailyBarRequest{}, err
|
|
}
|
|
req.From = from
|
|
req.To = to
|
|
return req, nil
|
|
}
|
|
|
|
// parseImportDate parses an optional YYYYMMDD date. An empty value yields the
|
|
// zero time; a malformed value is an error.
|
|
func parseImportDate(field, value string) (time.Time, error) {
|
|
if value == "" {
|
|
return time.Time{}, nil
|
|
}
|
|
t, err := time.Parse(importDateLayout, value)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("daily bar import payload: invalid %s date %q: %w", field, value, err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// importTimeframes is the closed set of valid import timeframe values.
|
|
// Only empty (defaults to daily) and "daily" are accepted; explicit minute
|
|
// values (e.g. "minute", "1m", "5m", "minute_1", "minute_5") are rejected as
|
|
// a typed error before reaching the importer.
|
|
var importTimeframes = map[string]bool{
|
|
"": true,
|
|
"daily": true,
|
|
}
|
|
|
|
// DecodeDailyBarImportPayload decodes and validates a KindImportDailyBars
|
|
// payload.
|
|
func DecodeDailyBarImportPayload(raw json.RawMessage) (DailyBarImportPayload, error) {
|
|
var p DailyBarImportPayload
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return DailyBarImportPayload{}, fmt.Errorf("decode daily bar import payload: %w", err)
|
|
}
|
|
if p.Provider == "" {
|
|
return DailyBarImportPayload{}, fmt.Errorf("daily bar import payload: provider is required")
|
|
}
|
|
if p.SelectorKind == "" {
|
|
return DailyBarImportPayload{}, fmt.Errorf("daily bar import payload: selector_kind is required")
|
|
}
|
|
if len(p.Symbols) == 0 {
|
|
return DailyBarImportPayload{}, fmt.Errorf("daily bar import payload: symbols is required")
|
|
}
|
|
if p.Timeframe != "" && !importTimeframes[p.Timeframe] {
|
|
return DailyBarImportPayload{}, fmt.Errorf("daily bar import payload: unsupported timeframe %q (only empty or \"daily\" is supported)", p.Timeframe)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// importTimeframe resolves the payload's optional timeframe to a domain
|
|
// Timeframe value. Empty defaults to daily; non-daily values are returned as a
|
|
// typed error so that the caller can reject them before any fetch.
|
|
func importTimeframe(tf string) (market.Timeframe, error) {
|
|
if tf == "" || tf == "daily" {
|
|
return market.TimeframeDaily, nil
|
|
}
|
|
return market.TimeframeDaily, fmt.Errorf("import_daily_bars job: unsupported timeframe %q (only daily is supported)", tf)
|
|
}
|
|
|
|
// RegisterDailyBarImportHandler registers the real KindImportDailyBars handler,
|
|
// replacing the built-in placeholder. It decodes the payload into a daily bar
|
|
// request, gates it through the provider capability matrix (rejecting provider
|
|
// mismatches and unsupported market/venue/daily-bar combinations before any
|
|
// fetch), and dispatches to the importer. RegisterBuiltins still registers all
|
|
// placeholders first; callers invoke this afterwards to wire the live importer.
|
|
func RegisterDailyBarImportHandler(runner *Runner, capability market.ProviderCapability, imp DailyBarImporter) {
|
|
runner.Register(KindImportDailyBars, func(ctx context.Context, payload json.RawMessage) error {
|
|
p, err := DecodeDailyBarImportPayload(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tf, err := importTimeframe(p.Timeframe)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
decision := capability.CheckBars(market.ProviderCapabilityRequest{
|
|
Provider: market.Provider(p.Provider),
|
|
Market: market.Market(p.Market),
|
|
Venue: market.Venue(p.Venue),
|
|
Timeframe: tf,
|
|
})
|
|
if !decision.Accepted {
|
|
return fmt.Errorf("daily bar import payload: %s", decision.Reason)
|
|
}
|
|
req, err := p.request()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := imp.ImportDailyBars(ctx, req); err != nil {
|
|
return fmt.Errorf("import daily bars job: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|