176 lines
4.6 KiB
Go
176 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/providers/kis"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/scheduler"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres"
|
|
)
|
|
|
|
// TestStoreBackedDepsWiresBacktestSurfaces guards against a regression where the
|
|
// worker process serves backtest reads but leaves the start surface (Starter)
|
|
// unwired, which would advertise no backtest-start capability and reject every
|
|
// start command. The store is built with a nil pool because the wiring path only
|
|
// constructs adapters; it never touches the database at startup.
|
|
func TestStoreBackedDepsWiresBacktestSurfaces(t *testing.T) {
|
|
runner := jobs.NewRunner()
|
|
deps := storeBackedDeps(runner, postgres.NewStore(nil))
|
|
|
|
if deps.Starter == nil {
|
|
t.Error("expected backtest Starter to be wired")
|
|
}
|
|
if deps.Analysis == nil || deps.Results == nil {
|
|
t.Error("expected backtest read stores to be wired")
|
|
}
|
|
if deps.Instruments == nil || deps.Bars == nil {
|
|
t.Error("expected market stores to be wired")
|
|
}
|
|
if deps.DailyBarImporter == nil {
|
|
t.Error("expected daily bar importer to be wired")
|
|
}
|
|
if deps.Paper == nil {
|
|
t.Error("expected paper trading service to be wired")
|
|
}
|
|
if deps.Live == nil {
|
|
t.Error("expected live trading service to be wired (audit query surface must be up even without a broker)")
|
|
}
|
|
|
|
// Wiring registers the import and run-backtest job handlers on the runner, so
|
|
// it must hold at least those two handlers after wiring.
|
|
if runner.Len() < 2 {
|
|
t.Errorf("expected wiring to register job handlers, runner holds %d", runner.Len())
|
|
}
|
|
}
|
|
|
|
func TestStartScheduler(t *testing.T) {
|
|
// 1. Scheduler disabled (no config path)
|
|
cfg := config.Config{
|
|
SchedulerEnabled: false,
|
|
}
|
|
runner := jobs.NewRunner()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
err := startScheduler(ctx, cfg, runner)
|
|
if err != nil {
|
|
t.Fatalf("expected nil error for disabled scheduler, got %v", err)
|
|
}
|
|
|
|
// 2. Scheduler enabled with invalid config path (should fail)
|
|
cfg.SchedulerEnabled = true
|
|
cfg.ScheduleConfigPath = "non_existent_file.yaml"
|
|
err = startScheduler(ctx, cfg, runner)
|
|
if err == nil {
|
|
t.Fatal("expected error for non existent schedule config, got nil")
|
|
}
|
|
|
|
// 3. Scheduler enabled with valid config
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, "schedule.yaml")
|
|
if err := os.WriteFile(tmpFile, []byte(`
|
|
schedules:
|
|
- name: kr-core-daily
|
|
provider: kis
|
|
selector:
|
|
kind: watchlist
|
|
market: KR
|
|
venue: KRX
|
|
name: kr-core
|
|
symbols: ["005930"]
|
|
timeframe: daily
|
|
cadence: daily
|
|
timezone: Asia/Seoul
|
|
backfill_window: 3d
|
|
parallelism_limit: 2
|
|
`), 0644); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
cfg.ScheduleConfigPath = tmpFile
|
|
err = startScheduler(ctx, cfg, runner)
|
|
if err != nil {
|
|
t.Fatalf("expected no error for valid config, got %v", err)
|
|
}
|
|
}
|
|
|
|
type mockExecutor struct {
|
|
mu sync.Mutex
|
|
dispatched int
|
|
}
|
|
|
|
func (m *mockExecutor) Execute(ctx context.Context, job jobs.Job) error {
|
|
m.mu.Lock()
|
|
m.dispatched++
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func TestSchedulerLoopDispatchesTickAndStops(t *testing.T) {
|
|
schedCfg := scheduler.Config{
|
|
Schedules: []scheduler.ScheduleConfig{
|
|
{
|
|
Name: "kr-core-daily-mock",
|
|
Provider: "kis",
|
|
Selector: scheduler.SelectorConfig{
|
|
Kind: "watchlist",
|
|
Market: "KR",
|
|
Venue: "KRX",
|
|
Name: "kr-core",
|
|
Symbols: []string{"005930"},
|
|
},
|
|
Timeframe: "daily",
|
|
Cadence: "daily",
|
|
Timezone: "Asia/Seoul",
|
|
BackfillWindow: "3d",
|
|
ParallelismLimit: 2,
|
|
},
|
|
},
|
|
}
|
|
|
|
exec := &mockExecutor{}
|
|
opts := scheduler.TickOptions{
|
|
Capabilities: scheduler.CapabilityMap(kis.Capability()),
|
|
Executor: exec,
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ticks := make(chan time.Time)
|
|
|
|
loopDone := make(chan struct{})
|
|
go func() {
|
|
runSchedulerLoop(ctx, schedCfg, opts, ticks)
|
|
close(loopDone)
|
|
}()
|
|
|
|
// Send 1 tick
|
|
ticks <- time.Now()
|
|
|
|
// Wait briefly for dispatch
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
exec.mu.Lock()
|
|
dispatchedVal := exec.dispatched
|
|
exec.mu.Unlock()
|
|
|
|
if dispatchedVal == 0 {
|
|
t.Error("expected at least 1 job dispatch on tick")
|
|
}
|
|
|
|
// Cancel context to shutdown
|
|
cancel()
|
|
|
|
select {
|
|
case <-loopDone:
|
|
// success: loop stopped
|
|
case <-time.After(1 * time.Second):
|
|
t.Error("scheduler loop did not stop on context cancellation")
|
|
}
|
|
}
|