paper trading command workflow를 진행 상태로 열고, loop smoke evidence와 후속 risk/order/UI handoff plan을 기록한다. CLI 상태 출력에는 risk 기본값과 equity summary를 포함해 headless 검증 key가 안정적으로 남도록 한다.
210 lines
6.4 KiB
Go
210 lines
6.4 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
|
|
sawEquity := 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 _, ok := rec["equity_point_count"]; ok {
|
|
if _, ok := rec["latest_equity"]; ok {
|
|
sawEquity = 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")
|
|
}
|
|
if !sawEquity {
|
|
t.Error("expected fixture missing equity_point_count/latest_equity on a paper 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"}}},
|
|
},
|
|
EquityCurve: []*altv1.BacktestEquityPoint{
|
|
{TimestampUnixMs: 1746057600000, Equity: &altv1.Price{Amount: &altv1.Decimal{Value: "10000000"}}},
|
|
{TimestampUnixMs: 1747267200000, Equity: &altv1.Price{Amount: &altv1.Decimal{Value: "10000100"}}},
|
|
},
|
|
}
|
|
}
|
|
|
|
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",
|
|
"risk=not_evaluated",
|
|
"equity_point_count=2",
|
|
"latest_equity=10000100",
|
|
} {
|
|
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") || !strings.Contains(out, "risk=not_evaluated") {
|
|
t.Errorf("output %q missing paper state fields", out)
|
|
}
|
|
if !strings.Contains(out, "equity_point_count=2") || !strings.Contains(out, "latest_equity=10000100") {
|
|
t.Errorf("output %q missing equity summary 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)
|
|
}
|
|
}
|