alt/services/worker/internal/jobs/backtest_jobs.go
toki a5710b20ff feat(backtest): run canceled status support and phase progress update
Backtest run handler에 context.Canceled 상태 전이를 추가한다.
executor가 context.Canceled를 반환하면 run 상태를 canceled로 설정한다.
에러 메시지 포맷팅도 동적 상태로 변경했다.

agent-roadmap을 업데이트한다.
Backtest Engine Baseline을 완료 처리하고 archive로 이동한다.
Backtest Analysis Surface를 진행 중 상태로 전환한다.
Task 검증 조건을 추가하고 완료 기준을 정리한다.

테스트 추가:
- context.Canceled 전이 테스트를 추가한다.
2026-05-30 12:53:34 +09:00

164 lines
4.9 KiB
Go

package jobs
import (
"context"
"encoding/json"
"errors"
"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/canceled.
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 errors.Is(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 errors.Is(execErr, context.Canceled) {
run.Status = backtest.RunStatusCanceled
}
if upsertErr := store.UpsertRun(ctx, run); upsertErr != nil {
return fmt.Errorf("failed to transition run to %s (original error: %v): %w", run.Status, 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
})
}