alt/apps/cli/internal/operator/runner_backtest_test.go
toki 1c11a17eff feat: operator headless workflow validation and backtest support
- Implement backtest workflow validation for headless operator mode
- Add handoff evidence tracking for backtest runs
- Update operator client, runner, scenario, and output modules
- Add test files for handoff, backtest runner, and error handling
- Add test data for backtest scenarios and validation
2026-06-01 13:21:20 +09:00

238 lines
5.6 KiB
Go

package operator
import (
"strings"
"sync"
"testing"
"time"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
)
func TestRunBacktestRequestOutputsRunID(t *testing.T) {
api := &fakeAPI{
startBacktestResp: &altv1.StartBacktestResponse{
Run: &altv1.BacktestRun{
Id: "run-12345",
Status: altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_PENDING,
},
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "backtest_run_request",
Steps: []Step{
{
ID: "start",
Action: ActionStartBacktest,
Request: Request{
StrategyID: "strategy-v1",
Market: "kr",
Timeframe: "daily",
FromUnixMs: 1746057600000,
ToUnixMs: 1747267200000,
},
Expect: Expect{
Status: "ok",
},
},
},
}
out, code := runScenario(t, sc, url)
if code != codeOK {
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
}
if !strings.Contains(out, "run.id=run-12345") {
t.Errorf("output %q missing run.id=run-12345", out)
}
if !strings.Contains(out, "run.status=pending") {
t.Errorf("output %q missing run.status=pending", out)
}
got := api.lastStartBacktestReq()
if got == nil {
t.Fatal("server did not receive a start backtest request")
}
if got.GetSpec().GetStrategyId() != "strategy-v1" {
t.Errorf("server received strategy_id = %q, want strategy-v1", got.GetSpec().GetStrategyId())
}
}
func TestRunBacktestPollingStopsOnSucceeded(t *testing.T) {
var mu sync.Mutex
pollCount := 0
api := &fakeAPI{
getBacktestRunFunc: func(req *altv1.GetBacktestRunRequest) (*altv1.GetBacktestRunResponse, error) {
mu.Lock()
defer mu.Unlock()
pollCount++
status := altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_RUNNING
if pollCount >= 3 {
status = altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_SUCCEEDED
}
return &altv1.GetBacktestRunResponse{
Run: &altv1.BacktestRun{
Id: req.GetRunId(),
Status: status,
},
}, nil
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "backtest_run_polling",
Steps: []Step{
{
ID: "poll",
Action: ActionPollBacktestRun,
Request: Request{
RunID: "run-12345",
PollingInterval: Duration(50 * time.Millisecond),
},
Expect: Expect{
Status: "ok",
RunStatus: "succeeded",
},
},
},
}
out, code := runScenario(t, sc, url)
if code != codeOK {
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
}
if !strings.Contains(out, "run.status=succeeded") {
t.Errorf("output %q missing run.status=succeeded", out)
}
mu.Lock()
finalPollCount := pollCount
mu.Unlock()
if finalPollCount < 3 {
t.Errorf("pollCount = %d, want >= 3", finalPollCount)
}
}
func TestRunBacktestResultSummaryOutputsMetrics(t *testing.T) {
api := &fakeAPI{
getBacktestResultResp: &altv1.GetBacktestResultResponse{
Result: &altv1.BacktestResult{
RunId: "run-12345",
Summary: &altv1.BacktestSummaryMetrics{
StartingCash: &altv1.Price{Amount: &altv1.Decimal{Value: "10000000"}},
EndingEquity: &altv1.Price{Amount: &altv1.Decimal{Value: "12500000"}},
TotalReturn: &altv1.Decimal{Value: "0.25"},
TradeCount: 42,
},
},
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "backtest_result_summary",
Steps: []Step{
{
ID: "result",
Action: ActionGetBacktestResult,
Request: Request{
RunID: "run-12345",
},
Expect: Expect{
Status: "ok",
},
},
},
}
out, code := runScenario(t, sc, url)
if code != codeOK {
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
}
for _, want := range []string{
"run.id=run-12345",
"starting_cash=10000000",
"ending_equity=12500000",
"total_return=0.25",
"trade_count=42",
} {
if !strings.Contains(out, want) {
t.Errorf("output %q missing %q", out, want)
}
}
}
func TestRunBacktestMissingRunIDTypedError(t *testing.T) {
api := &fakeAPI{
getBacktestResultResp: &altv1.GetBacktestResultResponse{
Error: &altv1.ErrorInfo{
Code: "not_found",
Message: "missing run id",
},
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "backtest_missing_run_id",
Steps: []Step{
{
ID: "result",
Action: ActionGetBacktestResult,
Request: Request{
RunID: "nonexistent",
},
Expect: Expect{
Status: "error",
ErrorCode: "not_found",
},
},
},
}
out, code := runScenario(t, sc, url)
if code != codeOK {
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
}
if !strings.Contains(out, "error.code=not_found") {
t.Errorf("output %q missing error.code=not_found", out)
}
}
func TestRunBacktestPollingHonorsPollingTimeout(t *testing.T) {
// API will keep returning RUNNING, never terminal, so polling should hit polling_timeout
api := &fakeAPI{
getBacktestRunResp: &altv1.GetBacktestRunResponse{
Run: &altv1.BacktestRun{
Id: "run-12345",
Status: altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_RUNNING,
},
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "backtest_run_polling_timeout",
Steps: []Step{
{
ID: "poll",
Action: ActionPollBacktestRun,
Request: Request{
RunID: "run-12345",
PollingInterval: Duration(5 * time.Millisecond),
PollingTimeout: Duration(25 * time.Millisecond),
},
Expect: Expect{
Status: "transport_error", // expected to time out
},
},
},
}
out, code := runScenario(t, sc, url)
if code != codeTransport {
t.Fatalf("exit code = %d, want 3 (out=%q)", code, out)
}
if !strings.Contains(out, "status=transport_error") || !strings.Contains(out, "polling timed out") {
t.Errorf("output %q missing polling timeout markers", out)
}
}