diff --git a/agent-roadmap/archive/phase/backtest-loop/milestones/backtest-engine-baseline.md b/agent-roadmap/archive/phase/backtest-loop/milestones/backtest-engine-baseline.md new file mode 100644 index 0000000..12862e4 --- /dev/null +++ b/agent-roadmap/archive/phase/backtest-loop/milestones/backtest-engine-baseline.md @@ -0,0 +1,68 @@ +# Milestone: Backtest Engine Baseline + +## 위치 + +- Roadmap: `agent-roadmap/ROADMAP.md` +- Phase: `agent-roadmap/phase/backtest-loop/PHASE.md` + +## 목표 + +정규화된 일봉 데이터를 사용해 전략 실행, portfolio state, result summary가 재현 가능한 backtest loop를 만든다. 이 milestone은 백테스트 기반을 ALT 확장의 중심축으로 고정한다. + +## 상태 + +[완료] + +## 구현 잠금 + +- 상태: 해제 +- 결정 필요: 없음 + +## 범위 + +- daily bar 기반 strategy execution skeleton +- deterministic backtest run lifecycle +- starting cash, ending equity, trades/positions summary +- result persistence and query +- fixture data 기반 검증 + +## 기능 + +### Epic: [backtest-engine] Deterministic backtest engine + +일봉 기반 전략 실행, portfolio state, result 저장과 fixture 검증 흐름을 묶는다. + +- [x] [run-lifecycle] backtest run의 pending/running/succeeded/failed/canceled lifecycle이 있다. 검증: worker가 backtest 실행을 소유하고 API는 실행 요청/조회 경계만 담당한다. +- [x] [strategy-port] strategy를 engine에 주입할 port 또는 interface가 있다. +- [x] [portfolio-state] cash, position, equity를 계산하는 최소 portfolio state가 있다. +- [x] [result-store] backtest result가 저장되고 조회된다. 검증: 결과가 contracts/client에서 조회 가능한 형태로 연결될 수 있다. +- [x] [fixture-test] fixture daily bars로 deterministic test가 통과한다. 검증: 동일한 fixture와 strategy 입력으로 동일한 result가 나오고 `bin/test`와 `bin/lint`가 통과한다. + +## 완료 리뷰 + +- 상태: 승인됨 +- 요청일: 2026-05-30 +- 완료 근거: 모든 기능 Task와 검증 조건이 충족되었다. worker backtest handler가 pending/running/succeeded/failed/canceled 전이를 소유하고, contracts/client에는 backtest 요청/조회 payload와 parser boundary가 있다. `bin/test`와 `bin/lint`가 통과했다. +- 리뷰 필요: + - [x] 사용자가 완료 결과를 확인했다 + - [x] archive 이동을 승인했다 +- 리뷰 코멘트: 2026-05-30 사용자 요청으로 완료 처리하고 archive로 이동한다. + +## 범위 제외 + +- 실시간 전략 실행 +- paper/live order routing +- 고급 리스크 모델 +- 고급 성과 분석 지표 전체 + +## 작업 컨텍스트 + +- 관련 경로: `packages/domain/backtest/`, `services/worker/`, `packages/contracts/proto/alt/v1/backtest.proto` +- 표준선(선택): Go는 Hexagonal Architecture, light CQRS, domain event 표현을 기본 설계 프레임으로 삼는다. +- 선행 작업: Korea Daily Data Foundation +- 후속 작업: Backtest Analysis Surface +- 진행 근거(2026-05-30): `[strategy-port]`, `[portfolio-state]`는 `packages/domain/backtest/types.go`, `packages/domain/backtest/types_test.go`, `go test ./packages/domain/...`로 확인했다. +- 진행 근거(2026-05-30): `[run-lifecycle]`은 `services/worker/internal/jobs/backtest_jobs.go`, `services/worker/internal/jobs/backtest_jobs_test.go`, `services/api/internal/contracts/parser_map.go`, `packages/contracts/proto/alt/v1/backtest.proto`, `go test ./services/worker/internal/jobs/...`로 확인했다. +- 진행 근거(2026-05-30): `[result-store]`는 `services/worker/internal/storage/ports.go`, `services/worker/internal/storage/postgres/store.go`, `services/worker/internal/storage/postgres/mapping_test.go`, `packages/contracts/proto/alt/v1/backtest.proto`, `apps/client/lib/src/contracts/alt_contracts.dart`로 확인했다. +- 진행 근거(2026-05-30): `[fixture-test]`는 `services/worker/internal/backtest/fixture_test.go`, `services/worker/internal/backtest/engine_test.go`, `bin/test`, `bin/lint`로 확인했다. +- 확인 필요: 없음 diff --git a/agent-roadmap/current.md b/agent-roadmap/current.md index baf5a8d..c565d7a 100644 --- a/agent-roadmap/current.md +++ b/agent-roadmap/current.md @@ -9,9 +9,9 @@ ## 활성 Milestone -- [진행중] Backtest Engine Baseline +- [진행중] Backtest Analysis Surface - Phase: `agent-roadmap/phase/backtest-loop/PHASE.md` - - 경로: `agent-roadmap/phase/backtest-loop/milestones/backtest-engine-baseline.md` + - 경로: `agent-roadmap/phase/backtest-loop/milestones/backtest-analysis-surface.md` - [계획] Client Integration Clone Adoption - Phase: `agent-roadmap/phase/operator-surface/PHASE.md` - 경로: `agent-roadmap/phase/operator-surface/milestones/client-integration-clone-adoption.md` diff --git a/agent-roadmap/phase/backtest-loop/PHASE.md b/agent-roadmap/phase/backtest-loop/PHASE.md index 3056ff7..5c8d83f 100644 --- a/agent-roadmap/phase/backtest-loop/PHASE.md +++ b/agent-roadmap/phase/backtest-loop/PHASE.md @@ -13,10 +13,10 @@ 완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획, 스케치 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다. 완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. -- [진행중] Backtest Engine Baseline - - 경로: `agent-roadmap/phase/backtest-loop/milestones/backtest-engine-baseline.md` +- [완료] Backtest Engine Baseline + - 경로: `agent-roadmap/archive/phase/backtest-loop/milestones/backtest-engine-baseline.md` - 요약: 일봉 데이터를 기준으로 재현 가능한 backtest execution loop와 결과 저장을 만든다. -- [계획] Backtest Analysis Surface +- [진행중] Backtest Analysis Surface - 경로: `agent-roadmap/phase/backtest-loop/milestones/backtest-analysis-surface.md` - 요약: backtest 결과를 operator가 비교하고 해석할 수 있는 기본 분석 표면을 만든다. - [스케치] Finance Agent Research Sidecar Sketch diff --git a/agent-roadmap/phase/backtest-loop/milestones/backtest-analysis-surface.md b/agent-roadmap/phase/backtest-loop/milestones/backtest-analysis-surface.md index c54cdf1..3e95c30 100644 --- a/agent-roadmap/phase/backtest-loop/milestones/backtest-analysis-surface.md +++ b/agent-roadmap/phase/backtest-loop/milestones/backtest-analysis-surface.md @@ -11,7 +11,7 @@ backtest 결과를 단순 성공/실패가 아니라 operator가 비교하고 ## 상태 -[계획] +[진행중] ## 구현 잠금 @@ -26,30 +26,23 @@ backtest 결과를 단순 성공/실패가 아니라 operator가 비교하고 - 기본 comparison capability - client 표시를 위한 contract shape -## 필수 기능 +## 기능 ### Epic: [analysis-surface] Backtest result analysis operator가 backtest 결과를 조회, 비교, 해석할 수 있는 최소 분석 표면을 묶는다. -- [ ] [summary-metrics] ending equity 외 최소 summary metrics가 있다. -- [ ] [equity-curve] run별 equity curve 또는 동등한 time-series result를 저장/조회한다. -- [ ] [run-list] backtest run 목록과 상태를 조회할 수 있다. +- [ ] [summary-metrics] ending equity 외 최소 summary metrics가 있다. 검증: Flutter client가 표시할 수 있는 result contract가 존재한다. +- [ ] [equity-curve] run별 equity curve 또는 동등한 time-series result를 저장/조회한다. 검증: fixture 기반 result 조회 테스트가 통과한다. +- [ ] [run-list] backtest run 목록과 상태를 조회할 수 있다. 검증: worker/API/client 경계가 backtest result를 일관되게 다룬다. - [ ] [run-detail] 단일 run의 spec, status, result를 조회할 수 있다. -- [ ] [compare-runs] 둘 이상의 run을 비교할 기본 기준이 있다. - -## 완료 기준 - -- [ ] Flutter client가 표시할 수 있는 result contract가 존재한다. -- [ ] worker/API/client 경계가 backtest result를 일관되게 다룬다. -- [ ] fixture 기반 result 조회 테스트가 통과한다. -- [ ] `bin/test`와 `bin/lint`가 통과한다. +- [ ] [compare-runs] 둘 이상의 run을 비교할 기본 기준이 있다. 검증: `bin/test`와 `bin/lint`가 통과한다. ## 완료 리뷰 - 상태: 없음 - 요청일: 없음 -- 완료 근거: 아직 필수 기능과 완료 기준이 충족되지 않았다. +- 완료 근거: 아직 기능 Task와 각 Task의 검증 조건이 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 diff --git a/services/worker/internal/jobs/backtest_jobs.go b/services/worker/internal/jobs/backtest_jobs.go index 4e389e7..7efc215 100644 --- a/services/worker/internal/jobs/backtest_jobs.go +++ b/services/worker/internal/jobs/backtest_jobs.go @@ -3,6 +3,7 @@ package jobs import ( "context" "encoding/json" + "errors" "fmt" "time" @@ -78,7 +79,7 @@ func DecodeRunBacktestPayload(raw json.RawMessage) (RunBacktestPayload, error) { } // RegisterRunBacktestHandler registers the concrete KindRunBacktest job handler. -// It manages transition of run states: Decodes payload -> status:running -> call executor -> status:succeeded/failed. +// It manages transition of run states: Decodes payload -> status:running -> call executor -> status:succeeded/failed/canceled. func RegisterRunBacktestHandler( runner *Runner, store storage.BacktestRunStore, @@ -106,7 +107,7 @@ func RegisterRunBacktestHandler( currentTime := now().UTC() run, err := store.GetRun(ctx, runID) if err != nil { - if err == storage.ErrRunNotFound { + if errors.Is(err, storage.ErrRunNotFound) { // Create new run in pending status first run = backtest.Run{ ID: runID, @@ -144,8 +145,11 @@ func RegisterRunBacktestHandler( 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 failed (original error: %v): %w", execErr, upsertErr) + return fmt.Errorf("failed to transition run to %s (original error: %v): %w", run.Status, execErr, upsertErr) } return execErr } diff --git a/services/worker/internal/jobs/backtest_jobs_test.go b/services/worker/internal/jobs/backtest_jobs_test.go index a2729ba..af80db7 100644 --- a/services/worker/internal/jobs/backtest_jobs_test.go +++ b/services/worker/internal/jobs/backtest_jobs_test.go @@ -150,6 +150,57 @@ func TestRegisterRunBacktestHandlerTransitionsFailed(t *testing.T) { } } +func TestRegisterRunBacktestHandlerTransitionsCanceled(t *testing.T) { + runner := NewRunner() + store := &stubBacktestRunStore{runs: make(map[backtest.RunID]backtest.Run)} + executor := &stubBacktestExecutor{err: context.Canceled} + + fixedTime := time.Date(2026, 5, 30, 11, 0, 0, 0, time.UTC) + nowFunc := func() time.Time { return fixedTime } + + RegisterRunBacktestHandler(runner, store, executor, nowFunc) + + payload := json.RawMessage(`{ + "run_id": "run-123", + "strategy_id": "strat-abc", + "market": "KR", + "timeframe": "1d", + "from": "2026-05-01T00:00:00Z", + "to": "2026-05-15T00:00:00Z" + }`) + + job := Job{ + ID: "job-1", + Kind: KindRunBacktest, + Payload: payload, + } + + err := runner.Execute(context.Background(), job) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled error, got %v", err) + } + + if !executor.called { + t.Fatal("expected executor to be called, but it was not") + } + + if len(store.upsertCalls) != 3 { + t.Fatalf("expected 3 UpsertRun calls, got %d", len(store.upsertCalls)) + } + + if store.upsertCalls[0].Status != backtest.RunStatusPending { + t.Errorf("expected first transition to be pending, got %s", store.upsertCalls[0].Status) + } + + if store.upsertCalls[1].Status != backtest.RunStatusRunning { + t.Errorf("expected second transition to be running, got %s", store.upsertCalls[1].Status) + } + + if store.upsertCalls[2].Status != backtest.RunStatusCanceled { + t.Errorf("expected third transition to be canceled, got %s", store.upsertCalls[2].Status) + } +} + func TestRegisterRunBacktestHandlerExistsTransitionsSucceeded(t *testing.T) { runner := NewRunner() store := &stubBacktestRunStore{runs: make(map[backtest.RunID]backtest.Run)}