Backtest run handler에 context.Canceled 상태 전이를 추가한다. executor가 context.Canceled를 반환하면 run 상태를 canceled로 설정한다. 에러 메시지 포맷팅도 동적 상태로 변경했다. agent-roadmap을 업데이트한다. Backtest Engine Baseline을 완료 처리하고 archive로 이동한다. Backtest Analysis Surface를 진행 중 상태로 전환한다. Task 검증 조건을 추가하고 완료 기준을 정리한다. 테스트 추가: - context.Canceled 전이 테스트를 추가한다.
164 lines
4.9 KiB
Go
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
|
|
})
|
|
}
|