alt/services/worker/internal/jobs/backtest_starter.go

128 lines
4.1 KiB
Go

package jobs
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/services/worker/internal/storage"
)
// BacktestStarter turns a start command into a persisted pending run plus an
// asynchronous KindRunBacktest job. It reuses the existing Runner so the rail
// does not introduce a second execution engine: the start path only records the
// pending run and hands execution to the same handler the runner already owns.
type BacktestStarter struct {
runner *Runner
store storage.BacktestRunStore
newID func() string
now func() time.Time
// launch runs the execution goroutine. It is injectable so tests can run the
// job synchronously instead of racing a detached goroutine.
launch func(func())
}
// BacktestStarterOption customises a BacktestStarter for testing or wiring.
type BacktestStarterOption func(*BacktestStarter)
// WithStarterNow overrides the clock used for run timestamps.
func WithStarterNow(now func() time.Time) BacktestStarterOption {
return func(s *BacktestStarter) { s.now = now }
}
// WithStarterIDGenerator overrides the run id generator.
func WithStarterIDGenerator(newID func() string) BacktestStarterOption {
return func(s *BacktestStarter) { s.newID = newID }
}
// WithStarterLaunch overrides how the execution goroutine is launched. Tests use
// this to execute synchronously and observe the runner deterministically.
func WithStarterLaunch(launch func(func())) BacktestStarterOption {
return func(s *BacktestStarter) { s.launch = launch }
}
// NewBacktestStarter builds a starter bound to the given runner and run store.
func NewBacktestStarter(runner *Runner, store storage.BacktestRunStore, opts ...BacktestStarterOption) *BacktestStarter {
s := &BacktestStarter{
runner: runner,
store: store,
newID: newRunID,
now: time.Now,
launch: func(fn func()) { go fn() },
}
for _, opt := range opts {
opt(s)
}
return s
}
// StartBacktest records a pending run for the spec and schedules execution. It
// returns the pending run so the caller can answer the start request immediately
// while the runner finishes the work in the background.
func (s *BacktestStarter) StartBacktest(ctx context.Context, spec backtest.RunSpec) (backtest.Run, error) {
now := s.now().UTC()
run := backtest.Run{
ID: backtest.RunID(s.newID()),
Spec: spec,
Status: backtest.RunStatusPending,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.store.UpsertRun(ctx, run); err != nil {
return backtest.Run{}, fmt.Errorf("failed to persist pending run: %w", err)
}
var instIDs []string
for _, id := range spec.Selector.InstrumentIDs {
instIDs = append(instIDs, string(id))
}
payload := RunBacktestPayload{
RunID: string(run.ID),
StrategyID: string(spec.StrategyID),
Market: string(spec.Market),
Timeframe: string(spec.Timeframe),
From: spec.From.UTC().Format(time.RFC3339),
To: spec.To.UTC().Format(time.RFC3339),
Selector: RunBacktestSelectorPayload{
InstrumentIDs: instIDs,
Symbols: spec.Selector.Symbols,
},
}
raw, err := json.Marshal(payload)
if err != nil {
return backtest.Run{}, fmt.Errorf("failed to encode run backtest payload: %w", err)
}
job := Job{
ID: string(run.ID),
Kind: KindRunBacktest,
Status: StatusPending,
Payload: raw,
}
// Execution is detached from the request lifecycle: the start response must
// not block on a long-running backtest, so the goroutine uses a background
// context instead of the request context.
s.launch(func() {
_ = s.runner.Execute(context.Background(), job)
})
return run, nil
}
// newRunID returns a random hex run identifier. crypto/rand keeps it collision
// resistant without pulling an external uuid dependency into the worker module.
func newRunID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// rand.Read failures are practically impossible; fall back to a
// timestamp-based id so a start never panics.
return fmt.Sprintf("run-%d", time.Now().UTC().UnixNano())
}
return "run-" + hex.EncodeToString(b[:])
}