alt/services/worker/internal/scheduler/config.go

374 lines
11 KiB
Go

// Package scheduler validates scheduled market data refresh configuration and
// renders dry-run evidence. It does not own the long-running worker loop; the
// runner can consume the validated shapes in a later orchestration layer.
package scheduler
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
"gopkg.in/yaml.v3"
)
const (
exitOK = 0
exitRejected = 1
exitUsage = 2
)
// Config is the top-level YAML shape for scheduled market data refresh.
type Config struct {
Schedules []ScheduleConfig `yaml:"schedules"`
}
// ScheduleConfig declares one named refresh schedule.
type ScheduleConfig struct {
Name string `yaml:"name"`
Provider string `yaml:"provider"`
Selector SelectorConfig `yaml:"selector"`
Timeframe string `yaml:"timeframe"`
Cadence string `yaml:"cadence"`
Timezone string `yaml:"timezone"`
BackfillWindow string `yaml:"backfill_window"`
ParallelismLimit int `yaml:"parallelism_limit"`
}
// SelectorConfig is the named universe selector for a schedule.
type SelectorConfig struct {
Kind string `yaml:"kind"`
Market string `yaml:"market"`
Venue string `yaml:"venue"`
Name string `yaml:"name"`
Symbols []string `yaml:"symbols"`
}
// ValidationOptions carries deterministic inputs for dry-run validation.
type ValidationOptions struct {
Now time.Time
Capabilities map[market.Provider]market.ProviderCapability
}
// Validation reports every schedule's dry-run status.
type Validation struct {
Items []ValidationItem
}
// HasRejected reports whether any item was rejected.
func (v Validation) HasRejected() bool {
for _, item := range v.Items {
if item.Status == "rejected" {
return true
}
}
return false
}
// ValidationItem is a stable text/JSONL evidence row.
type ValidationItem struct {
Schedule string `json:"schedule"`
Status string `json:"status"`
Provider string `json:"provider,omitempty"`
Selector string `json:"selector,omitempty"`
Timeframe string `json:"timeframe,omitempty"`
Cadence string `json:"cadence,omitempty"`
Timezone string `json:"timezone,omitempty"`
BackfillWindow string `json:"backfill_window,omitempty"`
ParallelismLimit int `json:"parallelism_limit,omitempty"`
NextRun string `json:"next_run,omitempty"`
Reason string `json:"reason,omitempty"`
}
// LoadConfig reads a YAML schedule config.
func LoadConfig(r io.Reader) (Config, error) {
var cfg Config
dec := yaml.NewDecoder(r)
dec.KnownFields(true)
if err := dec.Decode(&cfg); err != nil {
return Config{}, fmt.Errorf("decode schedule config: %w", err)
}
return cfg, nil
}
// ValidateConfig validates config and computes the next dry-run window.
func ValidateConfig(cfg Config, opts ValidationOptions) Validation {
now := opts.Now
if now.IsZero() {
now = time.Now()
}
if len(cfg.Schedules) == 0 {
return Validation{Items: []ValidationItem{{
Schedule: "unknown",
Status: "rejected",
Reason: "no schedules declared",
}}}
}
seen := make(map[string]bool)
items := make([]ValidationItem, 0, len(cfg.Schedules))
for _, sched := range cfg.Schedules {
items = append(items, validateSchedule(sched, opts.Capabilities, now, seen))
}
return Validation{Items: items}
}
func validateSchedule(s ScheduleConfig, caps map[market.Provider]market.ProviderCapability, now time.Time, seen map[string]bool) ValidationItem {
name := strings.TrimSpace(s.Name)
if name == "" {
name = "unknown"
}
item := ValidationItem{
Schedule: name,
Provider: strings.TrimSpace(s.Provider),
Selector: strings.TrimSpace(s.Selector.Name),
Timeframe: strings.TrimSpace(s.Timeframe),
Timezone: strings.TrimSpace(s.Timezone),
ParallelismLimit: s.ParallelismLimit,
}
var reasons []string
if s.Name == "" {
reasons = append(reasons, "name is required")
} else if seen[s.Name] {
reasons = append(reasons, fmt.Sprintf("duplicate schedule name %q", s.Name))
}
seen[s.Name] = true
if item.Provider == "" {
reasons = append(reasons, "provider is required")
}
if item.Selector == "" {
reasons = append(reasons, "selector.name is required")
}
if s.Selector.Kind == "" {
reasons = append(reasons, "selector.kind is required")
}
if s.Selector.Venue == "" {
reasons = append(reasons, "selector.venue is required")
}
if len(s.Selector.Symbols) == 0 {
reasons = append(reasons, "selector.symbols is required")
}
if s.Timeframe == "" {
reasons = append(reasons, "timeframe is required")
}
if s.Cadence == "" {
reasons = append(reasons, "cadence is required")
}
if s.Timezone == "" {
reasons = append(reasons, "timezone is required")
}
if s.BackfillWindow == "" {
reasons = append(reasons, "backfill_window is required")
}
if s.ParallelismLimit <= 0 {
reasons = append(reasons, "parallelism_limit must be greater than zero")
}
cadence, err := parseWindow(s.Cadence)
if err != nil {
reasons = append(reasons, fmt.Sprintf("invalid cadence %q: %v", s.Cadence, err))
} else {
item.Cadence = cadence.String()
}
backfill, err := parseWindow(s.BackfillWindow)
if err != nil {
reasons = append(reasons, fmt.Sprintf("invalid backfill_window %q: %v", s.BackfillWindow, err))
} else {
item.BackfillWindow = backfill.String()
}
loc, err := time.LoadLocation(s.Timezone)
if err != nil && s.Timezone != "" {
reasons = append(reasons, fmt.Sprintf("invalid timezone %q: %v", s.Timezone, err))
}
tf, tfLabel, err := parseTimeframe(s.Timeframe)
if err != nil {
reasons = append(reasons, err.Error())
} else {
item.Timeframe = tfLabel
}
provider := market.Provider(s.Provider)
capability, ok := caps[provider]
if s.Provider != "" && !ok {
reasons = append(reasons, fmt.Sprintf("unsupported provider %q", s.Provider))
}
if ok && err == nil {
decision := capability.CheckBars(market.ProviderCapabilityRequest{
Provider: provider,
Market: market.Market(s.Selector.Market),
Venue: market.Venue(s.Selector.Venue),
Timeframe: tf,
})
if !decision.Accepted {
reasons = append(reasons, "unsupported provider/timeframe combination: "+decision.Reason)
}
}
if len(reasons) > 0 {
item.Status = "rejected"
item.Reason = strings.Join(reasons, "; ")
return item
}
item.Status = "valid"
item.NextRun = nextWindow(now, loc, cadence).Format(time.RFC3339)
return item
}
func parseTimeframe(value string) (market.Timeframe, string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "daily", "1d":
return market.TimeframeDaily, "daily", nil
case "monthly", "1mo":
return market.TimeframeMonthly, "monthly", fmt.Errorf("unsupported timeframe %q (only daily is supported)", value)
case "minute_1", "1m":
return market.TimeframeMin1, "minute_1", fmt.Errorf("unsupported timeframe %q (only daily is supported)", value)
case "minute_5", "5m":
return market.TimeframeMin5, "minute_5", fmt.Errorf("unsupported timeframe %q (only daily is supported)", value)
default:
return "", "", fmt.Errorf("unsupported timeframe %q (only daily is supported)", value)
}
}
func parseWindow(value string) (time.Duration, error) {
trimmed := strings.TrimSpace(strings.ToLower(value))
if trimmed == "daily" {
return 24 * time.Hour, nil
}
if strings.HasSuffix(trimmed, "d") && len(trimmed) > 1 {
days, err := strconv.Atoi(strings.TrimSuffix(trimmed, "d"))
if err != nil || days <= 0 {
return 0, fmt.Errorf("expected positive day count")
}
return time.Duration(days) * 24 * time.Hour, nil
}
d, err := time.ParseDuration(trimmed)
if err != nil {
return 0, err
}
if d <= 0 {
return 0, fmt.Errorf("expected positive duration")
}
return d, nil
}
func nextWindow(now time.Time, loc *time.Location, cadence time.Duration) time.Time {
localNow := now.In(loc)
dayStart := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc)
if !localNow.After(dayStart) {
return dayStart
}
elapsed := localNow.Sub(dayStart)
steps := elapsed/cadence + 1
return dayStart.Add(steps * cadence)
}
// WriteText renders stable operator-facing dry-run lines.
func WriteText(w io.Writer, validation Validation) error {
for _, item := range validation.Items {
if item.Status == "rejected" {
if _, err := fmt.Fprintf(w, "schedule=%s status=rejected reason=%q\n", item.Schedule, item.Reason); err != nil {
return err
}
continue
}
if _, err := fmt.Fprintf(w,
"schedule=%s status=valid provider=%s selector=%s timeframe=%s cadence=%s timezone=%s backfill_window=%s parallelism_limit=%d next_run=%s\n",
item.Schedule, item.Provider, item.Selector, item.Timeframe, item.Cadence, item.Timezone, item.BackfillWindow, item.ParallelismLimit, item.NextRun,
); err != nil {
return err
}
}
return nil
}
// WriteJSONL renders one JSON object per validation item.
func WriteJSONL(w io.Writer, validation Validation) error {
enc := json.NewEncoder(w)
for _, item := range validation.Items {
if err := enc.Encode(item); err != nil {
return err
}
}
return nil
}
// RunCheck implements the schedule config validation command.
func RunCheck(args []string, stdout, stderr io.Writer, now func() time.Time, caps map[market.Provider]market.ProviderCapability) int {
fs := flag.NewFlagSet("alt-worker-schedule-check", flag.ContinueOnError)
fs.SetOutput(stderr)
file := fs.String("file", "", "path to schedule YAML file")
output := fs.String("output", "text", "output format: text or jsonl")
at := fs.String("at", "", "dry-run reference time in RFC3339")
if err := fs.Parse(args); err != nil {
return exitUsage
}
if *file == "" {
fmt.Fprintln(stderr, "schedule config file is required: --file <path>")
return exitUsage
}
f, err := os.Open(*file)
if err != nil {
fmt.Fprintf(stderr, "open schedule config: %v\n", err)
return exitUsage
}
defer f.Close()
cfg, err := LoadConfig(f)
if err != nil {
fmt.Fprintf(stderr, "validate schedule config: %v\n", err)
return exitUsage
}
nowValue := now()
if *at != "" {
parsed, err := time.Parse(time.RFC3339, *at)
if err != nil {
fmt.Fprintf(stderr, "invalid --at time %q: %v\n", *at, err)
return exitUsage
}
nowValue = parsed
}
validation := ValidateConfig(cfg, ValidationOptions{Now: nowValue, Capabilities: caps})
switch *output {
case "text":
err = WriteText(stdout, validation)
case "jsonl":
err = WriteJSONL(stdout, validation)
default:
fmt.Fprintf(stderr, "unknown output format %q (want text or jsonl)\n", *output)
return exitUsage
}
if err != nil {
fmt.Fprintf(stderr, "write schedule validation: %v\n", err)
return exitUsage
}
if validation.HasRejected() {
return exitRejected
}
return exitOK
}
// CapabilityMap builds a deterministic capability map for callers.
func CapabilityMap(capabilities ...market.ProviderCapability) map[market.Provider]market.ProviderCapability {
out := make(map[market.Provider]market.ProviderCapability, len(capabilities))
for _, capability := range capabilities {
out[capability.Provider] = capability
}
return out
}
// SortedScheduleNames returns names in deterministic order for future status
// surfaces without exposing internal map iteration order.
func SortedScheduleNames(cfg Config) []string {
names := make([]string, 0, len(cfg.Schedules))
for _, sched := range cfg.Schedules {
names = append(names, sched.Name)
}
sort.Strings(names)
return names
}