- 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
222 lines
7.7 KiB
Go
222 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
workerbacktest "git.toki-labs.com/toki/alt/services/worker/internal/backtest"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/config"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/jobs"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/livetrading"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/aggregation"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/papertrading"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/providers/kis"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/scheduler"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/socket"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres"
|
|
)
|
|
|
|
func main() {
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
cfg := config.Load()
|
|
runner := jobs.NewRunner()
|
|
jobs.RegisterBuiltins(runner)
|
|
|
|
// Store-backed socket surfaces are optional at process startup. If the pool
|
|
// cannot be constructed the worker still serves hello, while market and
|
|
// backtest handlers report unavailable instead of panicking.
|
|
var deps socket.Deps
|
|
var refreshStatusStore *scheduler.RefreshStatusStore
|
|
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
slog.Error("failed to create database pool; store-backed socket surfaces disabled", "error", err)
|
|
} else {
|
|
defer pool.Close()
|
|
store := postgres.NewStore(pool)
|
|
deps = storeBackedDeps(runner, store)
|
|
|
|
// Wire refresh status store so the scheduler tick and socket handler
|
|
// share the same in-memory store and the operator-facing query
|
|
// surface reports actual runtime state.
|
|
refreshStatusStore = scheduler.NewRefreshStatusStore()
|
|
deps.RefreshStatus = refreshStatusStore
|
|
|
|
if err := startScheduler(ctx, cfg, runner, refreshStatusStore); err != nil {
|
|
slog.Error("failed to start scheduler", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
server := socket.NewServer(cfg, deps)
|
|
if err := server.Start(ctx); err != nil {
|
|
slog.Error("failed to start worker socket server", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
slog.Info("worker ready",
|
|
"redis_key_prefix", cfg.RedisKeyPrefix,
|
|
"worker_queue", cfg.WorkerQueue,
|
|
"handlers", runner.Len(),
|
|
"backtest_read_surface", deps.Analysis != nil,
|
|
"backtest_start_surface", deps.Starter != nil,
|
|
"market_read_surface", deps.Instruments != nil && deps.Bars != nil,
|
|
"market_import_surface", deps.DailyBarImporter != nil,
|
|
"live_audit_surface", deps.Live != nil,
|
|
"addr", fmt.Sprintf("%s:%d%s", cfg.Host, cfg.Port, cfg.SocketPath),
|
|
)
|
|
|
|
<-ctx.Done()
|
|
if err := server.Stop(); err != nil {
|
|
slog.Error("failed to stop worker socket server", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// storeBackedDeps wires every store-backed socket dependency around a single
|
|
// store, including the backtest execution rail. It is factored out of main so a
|
|
// unit test can assert the backtest start/read surfaces are wired without a live
|
|
// database. Backtest execution reads imported daily bars from storage and runs
|
|
// the bundled strategy resolver; the starter records the pending run and hands
|
|
// execution to the same runner the import jobs use, keeping a single async job
|
|
// rail rather than a second execution engine.
|
|
func storeBackedDeps(runner *jobs.Runner, store *postgres.Store) socket.Deps {
|
|
kisProvider := kis.NewLiveProvider(kis.NewClient(kis.ConfigFromEnv()))
|
|
// One importer instance backs both the async job handler and the socket
|
|
// import command so the operator-facing command and scheduled jobs share the
|
|
// same provider/store wiring.
|
|
kisImporter := importer.New(kisProvider, store)
|
|
jobs.RegisterDailyBarImportHandler(runner, kis.Capability(), kisImporter)
|
|
|
|
barSource := workerbacktest.NewStorageBarSource(store, store)
|
|
strategyPort := workerbacktest.NewBuiltInStrategyPort()
|
|
engine := workerbacktest.NewEngine(barSource, strategyPort, store)
|
|
jobs.RegisterRunBacktestHandler(runner, store, engine, time.Now)
|
|
starter := jobs.NewBacktestStarter(runner, store)
|
|
|
|
// Paper trading reuses the same imported-bar source and bundled strategy
|
|
// resolver as backtest, but runs synchronously into a process-local
|
|
// in-memory state keyed by account id (no durable store for readiness).
|
|
paperEngine := papertrading.NewEngine(barSource, strategyPort)
|
|
paperService := papertrading.NewService(paperEngine, time.Now)
|
|
|
|
// Live trading service: nil broker means live order operations are unavailable
|
|
// until a broker adapter is wired. The audit store is always injected so the
|
|
// audit query surface is available as soon as the database pool is up.
|
|
liveSvc := livetrading.NewService(nil)
|
|
liveSvc.SetAuditStore(store)
|
|
|
|
return socket.Deps{
|
|
Starter: starter,
|
|
Analysis: store,
|
|
Results: store,
|
|
Instruments: store,
|
|
Bars: store,
|
|
DailyBarImporter: kisImporter,
|
|
MonthlyAggregator: aggregation.NewAggregator(store, store),
|
|
Paper: paperService,
|
|
Live: liveSvc,
|
|
}
|
|
}
|
|
|
|
func startScheduler(ctx context.Context, cfg config.Config, runner *jobs.Runner, statusStore *scheduler.RefreshStatusStore) error {
|
|
if !cfg.SchedulerEnabled {
|
|
return nil
|
|
}
|
|
|
|
f, err := os.Open(cfg.ScheduleConfigPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open schedule config: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
schedCfg, err := scheduler.LoadConfig(f)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load schedule config: %w", err)
|
|
}
|
|
|
|
// Store the config so the refresh status store can compute next_run values
|
|
// for the socket handler surface.
|
|
if statusStore != nil {
|
|
statusStore.SetScheduleConfig(schedCfg)
|
|
}
|
|
|
|
capabilities := scheduler.CapabilityMap(kis.Capability())
|
|
validation := scheduler.ValidateConfig(schedCfg, scheduler.ValidationOptions{
|
|
Now: time.Now(),
|
|
Capabilities: capabilities,
|
|
})
|
|
if validation.HasRejected() {
|
|
var reasons []string
|
|
for _, item := range validation.Items {
|
|
if item.Status == "rejected" {
|
|
reasons = append(reasons, fmt.Sprintf("%s: %s", item.Schedule, item.Reason))
|
|
}
|
|
}
|
|
return fmt.Errorf("invalid schedule config: %s", strings.Join(reasons, "; "))
|
|
}
|
|
|
|
slog.Info("starting scheduler loop",
|
|
"scheduler_enabled", true,
|
|
"schedule_count", len(schedCfg.Schedules),
|
|
)
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
opts := scheduler.TickOptions{
|
|
Capabilities: capabilities,
|
|
Executor: runner,
|
|
}
|
|
|
|
runSchedulerLoop(ctx, schedCfg, opts, ticker.C, statusStore)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func runSchedulerLoop(ctx context.Context, schedCfg scheduler.Config, opts scheduler.TickOptions, ticks <-chan time.Time, statusStore *scheduler.RefreshStatusStore) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
slog.Info("stopping scheduler loop")
|
|
return
|
|
case <-ticks:
|
|
opts.Now = time.Now()
|
|
res, err := scheduler.RunTick(ctx, schedCfg, opts)
|
|
if err != nil {
|
|
slog.Error("scheduler tick execution error", "error", err)
|
|
continue
|
|
}
|
|
for _, item := range res.Items {
|
|
if item.Status == "failed" {
|
|
slog.Error("scheduler job failed", "schedule", item.Schedule, "symbol", item.Symbol, "error", item.Error)
|
|
} else if item.Status == "skipped" {
|
|
slog.Info("scheduler job skipped", "schedule", item.Schedule, "symbol", item.Symbol, "reason", item.Error)
|
|
} else {
|
|
slog.Info("scheduler job succeeded", "schedule", item.Schedule, "symbol", item.Symbol, "readiness", item.Readiness, "freshness", item.Freshness)
|
|
}
|
|
}
|
|
// Record tick results in the refresh status store so the socket
|
|
// handler surface reports actual runtime state.
|
|
if statusStore != nil {
|
|
now := opts.Now
|
|
for _, sched := range schedCfg.Schedules {
|
|
statusStore.RecordTick(sched.Name, res.Items, now)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|