// 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 ") 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 }