alt/services/worker/internal/jobs/marketdata_jobs.go
toki bdbfe8228e feat: scheduled market data refresh - parser_map, refresh status model, CLI output, scheduler backfill, socket handlers
- Add parser_map for market data refresh configuration propagation (cli/worker/api)
- Implement refresh status model with SQLite persistence (status_store.go)
- Add scheduler refresh status headless output to CLI operator
- Extend backfill scheduler with status tracking (start/complete/fail)
- Add socket events for scheduler refresh status (start/complete/fail)
- Update proto definitions for refresh status
- Add generated PB files for client
2026-06-22 18:46:45 +09:00

175 lines
6.4 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.
//
// Both Register (for Execute) and RegisterWithResult (for ExecuteWithResult)
// are called so that the scheduler's executor can obtain actual import bar
// counts from the real runtime path instead of silently reporting
// "success with no evidence".
func RegisterDailyBarImportHandler(runner *Runner, capability market.ProviderCapability, imp DailyBarImporter) {
exec := func(ctx context.Context, payload json.RawMessage) (Result, error) {
p, err := DecodeDailyBarImportPayload(payload)
if err != nil {
return Result{}, err
}
tf, err := importTimeframe(p.Timeframe)
if err != nil {
return Result{}, 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 Result{}, fmt.Errorf("daily bar import payload: %s", decision.Reason)
}
req, err := p.request()
if err != nil {
return Result{}, err
}
res, err := imp.ImportDailyBars(ctx, req)
if err != nil {
return Result{}, fmt.Errorf("import daily bars job: %w", err)
}
return Result{
Bars: res.Bars,
Instruments: res.Instruments,
}, nil
}
runner.Register(KindImportDailyBars, func(ctx context.Context, payload json.RawMessage) error {
_, err := exec(ctx, payload)
return err
})
runner.RegisterWithResult(KindImportDailyBars, exec)
}