Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
191 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|