alt/services/worker/internal/jobs/backtest_jobs.go
toki 38b68315fc feat: backtest engine baseline implementation
- Add backtest proto definitions and generated code
- Update domain types for backtest results and fixtures
- Add PostgreSQL migrations for backtest tables
- Implement storage layer for backtest result persistence
- Add backtest job definitions and execution pipeline
- Remove obsolete agent-task documents for completed items
2026-05-30 12:13:45 +09:00

160 lines
4.8 KiB
Go

package jobs
import (
"context"
"encoding/json"
"fmt"
"time"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/services/worker/internal/storage"
)
// BacktestExecutor defines the interface for running backtests.
type BacktestExecutor interface {
Execute(ctx context.Context, run backtest.Run) error
}
// RunBacktestPayload is the decoded KindRunBacktest job payload.
type RunBacktestPayload struct {
RunID string `json:"run_id"`
StrategyID string `json:"strategy_id"`
Market string `json:"market"`
Timeframe string `json:"timeframe"`
From string `json:"from"`
To string `json:"to"`
}
// parsePayloadDate parses standard date strings supported by the payload.
func parsePayloadDate(value string) (time.Time, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02", "20060102"} {
if t, err := time.Parse(layout, value); err == nil {
return t.UTC(), nil
}
}
return time.Time{}, fmt.Errorf("invalid date format %q", value)
}
// DecodeRunBacktestPayload decodes and validates a KindRunBacktest payload.
func DecodeRunBacktestPayload(raw json.RawMessage) (RunBacktestPayload, error) {
var p RunBacktestPayload
if err := json.Unmarshal(raw, &p); err != nil {
return RunBacktestPayload{}, fmt.Errorf("decode run backtest payload: %w", err)
}
if p.RunID == "" {
return RunBacktestPayload{}, fmt.Errorf("run backtest payload: run_id is required")
}
if p.StrategyID == "" {
return RunBacktestPayload{}, fmt.Errorf("run backtest payload: strategy_id is required")
}
if p.Market == "" {
return RunBacktestPayload{}, fmt.Errorf("run backtest payload: market is required")
}
if p.Timeframe == "" {
return RunBacktestPayload{}, fmt.Errorf("run backtest payload: timeframe is required")
}
if p.From == "" {
return RunBacktestPayload{}, fmt.Errorf("run backtest payload: from is required")
}
if p.To == "" {
return RunBacktestPayload{}, fmt.Errorf("run backtest payload: to is required")
}
from, err := parsePayloadDate(p.From)
if err != nil {
return RunBacktestPayload{}, fmt.Errorf("run backtest payload: invalid from date: %w", err)
}
to, err := parsePayloadDate(p.To)
if err != nil {
return RunBacktestPayload{}, fmt.Errorf("run backtest payload: invalid to date: %w", err)
}
if from.After(to) {
return RunBacktestPayload{}, fmt.Errorf("run backtest payload: from date cannot be after to date")
}
return p, nil
}
// RegisterRunBacktestHandler registers the concrete KindRunBacktest job handler.
// It manages transition of run states: Decodes payload -> status:running -> call executor -> status:succeeded/failed.
func RegisterRunBacktestHandler(
runner *Runner,
store storage.BacktestRunStore,
executor BacktestExecutor,
now func() time.Time,
) {
runner.Register(KindRunBacktest, func(ctx context.Context, payload json.RawMessage) error {
p, err := DecodeRunBacktestPayload(payload)
if err != nil {
return err
}
runID := backtest.RunID(p.RunID)
fromTime, _ := parsePayloadDate(p.From)
toTime, _ := parsePayloadDate(p.To)
spec := backtest.RunSpec{
StrategyID: backtest.StrategyID(p.StrategyID),
Market: market.Market(p.Market),
Timeframe: market.Timeframe(p.Timeframe),
From: fromTime,
To: toTime,
}
currentTime := now().UTC()
run, err := store.GetRun(ctx, runID)
if err != nil {
if err == storage.ErrRunNotFound {
// Create new run in pending status first
run = backtest.Run{
ID: runID,
Spec: spec,
Status: backtest.RunStatusPending,
CreatedAt: currentTime,
UpdatedAt: currentTime,
}
if err := store.UpsertRun(ctx, run); err != nil {
return fmt.Errorf("failed to transition run to pending: %w", err)
}
// Transition to running
run.Status = backtest.RunStatusRunning
run.UpdatedAt = currentTime
} else {
// Other store errors prevent execution and are returned immediately
return fmt.Errorf("failed to get run: %w", err)
}
} else {
run.Spec = spec
run.Status = backtest.RunStatusRunning
run.UpdatedAt = currentTime
}
// Record running status
if err := store.UpsertRun(ctx, run); err != nil {
return fmt.Errorf("failed to transition run to running: %w", err)
}
// Execute
execErr := executor.Execute(ctx, run)
// Record final status
run.UpdatedAt = now().UTC()
if execErr != nil {
run.Status = backtest.RunStatusFailed
if upsertErr := store.UpsertRun(ctx, run); upsertErr != nil {
return fmt.Errorf("failed to transition run to failed (original error: %v): %w", execErr, upsertErr)
}
return execErr
}
run.Status = backtest.RunStatusSucceeded
if upsertErr := store.UpsertRun(ctx, run); upsertErr != nil {
return fmt.Errorf("failed to transition run to succeeded: %w", upsertErr)
}
return nil
})
}