374 lines
11 KiB
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
|
|
}
|