alt/apps/cli/internal/operator/runner_paper_test.go
toki aaf1af6387 feat(paper-trading): paper 상태 모니터링을 연결한다
Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
2026-06-05 15:14:41 +09:00

191 lines
5.6 KiB
Go

package operator
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
)
// TestPaperTradingFixtureIsValid guards the paper trading scenario fixture and
// its expected output: the YAML must pass dry-run validation and every expected
// JSONL line must parse and carry the paper account_id key the operator relies
// on for monitoring.
func TestPaperTradingFixtureIsValid(t *testing.T) {
yamlPath := filepath.Join("..", "..", "testdata", "operator", "paper_trading_state.yaml")
if _, err := LoadScenario(yamlPath); err != nil {
t.Fatalf("paper_trading_state.yaml failed validation: %v", err)
}
expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "paper_trading_state.jsonl")
f, err := os.Open(expectedPath)
if err != nil {
t.Fatalf("open expected fixture: %v", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
sawAccount := false
sawSummary := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("expected fixture line not valid JSON: %v", err)
}
if rec["scenario"] != "paper_trading_state" {
t.Errorf("line scenario = %v, want paper_trading_state", rec["scenario"])
}
if rec["account_id"] == "paper-1" {
sawAccount = true
}
if rec["type"] == "summary" {
sawSummary = true
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan expected fixture: %v", err)
}
if !sawAccount {
t.Error("expected fixture missing an account_id=paper-1 line")
}
if !sawSummary {
t.Error("expected fixture missing a summary line")
}
}
func paperState(accountID string) *altv1.PaperTradingState {
return &altv1.PaperTradingState{
AccountId: accountID,
Run: &altv1.BacktestRun{
Id: "paper-run-1",
Status: altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_SUCCEEDED,
},
Cash: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "9998950"}},
Positions: []*altv1.BacktestPosition{
{InstrumentId: "KRX:005930", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}, LastPrice: &altv1.Price{Amount: &altv1.Decimal{Value: "1150"}}},
},
Fills: []*altv1.BacktestTrade{
{InstrumentId: "KRX:005930", Side: "buy", Price: &altv1.Price{Amount: &altv1.Decimal{Value: "1050"}}},
},
}
}
func TestRunStartPaperTradingOutputsAccountState(t *testing.T) {
api := &fakeAPI{startPaperResp: &altv1.StartPaperTradingResponse{State: paperState("paper-1")}}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "paper_trading_state",
Steps: []Step{
{
ID: "start",
Action: ActionStartPaperTrading,
Request: Request{
AccountID: "paper-1",
StrategyID: "strategy-v1",
Market: "kr",
Timeframe: "daily",
FromUnixMs: 1746057600000,
ToUnixMs: 1747267200000,
StartingCash: "10000000",
},
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)
}
for _, want := range []string{
"account_id=paper-1",
"run.status=succeeded",
"cash=9998950",
"position_count=1",
"fill_count=1",
} {
if !strings.Contains(out, want) {
t.Errorf("output %q missing %q", out, want)
}
}
got := api.lastStartPaperReq()
if got == nil {
t.Fatal("server did not receive a start paper trading request")
}
if got.GetAccountId() != "paper-1" {
t.Errorf("server received account_id = %q, want paper-1", got.GetAccountId())
}
if got.GetStartingCash().GetCurrency() != altv1.Currency_CURRENCY_KRW {
t.Errorf("server received currency = %v, want KRW (derived from kr market)", got.GetStartingCash().GetCurrency())
}
if got.GetStartingCash().GetAmount().GetValue() != "10000000" {
t.Errorf("server received starting cash = %q, want 10000000", got.GetStartingCash().GetAmount().GetValue())
}
}
func TestRunGetPaperTradingStateOutputsFields(t *testing.T) {
api := &fakeAPI{getPaperStateResp: &altv1.GetPaperTradingStateResponse{State: paperState("paper-1")}}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "paper_trading_state",
Steps: []Step{
{
ID: "state",
Action: ActionGetPaperTradingState,
Request: Request{AccountID: "paper-1"},
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, "account_id=paper-1") || !strings.Contains(out, "position_count=1") {
t.Errorf("output %q missing paper state fields", out)
}
got := api.lastGetPaperStateReq()
if got == nil {
t.Fatal("server did not receive a get paper state request")
}
if got.GetAccountId() != "paper-1" {
t.Errorf("server received account_id = %q, want paper-1", got.GetAccountId())
}
}
func TestRunGetPaperTradingStateNotFoundTypedError(t *testing.T) {
api := &fakeAPI{getPaperStateResp: &altv1.GetPaperTradingStateResponse{
Error: &altv1.ErrorInfo{Code: "not_found", Message: "paper trading account not found"},
}}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "paper_trading_state",
Steps: []Step{
{
ID: "state",
Action: ActionGetPaperTradingState,
Request: Request{AccountID: "missing"},
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)
}
}