Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
251 lines
8.5 KiB
Go
251 lines
8.5 KiB
Go
package socket
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
|
|
"git.toki-labs.com/toki/alt/packages/domain/backtest"
|
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/papertrading"
|
|
protoSocket "git.toki-labs.com/toki/proto-socket/go"
|
|
)
|
|
|
|
type fakePaperService struct {
|
|
gotStart papertrading.StartRequest
|
|
gotAccount backtest.PaperAccountID
|
|
state papertrading.State
|
|
startErr error
|
|
stateErr error
|
|
}
|
|
|
|
func (f *fakePaperService) StartPaperTrading(ctx context.Context, req papertrading.StartRequest) (papertrading.State, error) {
|
|
f.gotStart = req
|
|
if f.startErr != nil {
|
|
return papertrading.State{}, f.startErr
|
|
}
|
|
return f.state, nil
|
|
}
|
|
|
|
func (f *fakePaperService) GetPaperTradingState(ctx context.Context, accountID backtest.PaperAccountID) (papertrading.State, error) {
|
|
f.gotAccount = accountID
|
|
if f.stateErr != nil {
|
|
return papertrading.State{}, f.stateErr
|
|
}
|
|
return f.state, nil
|
|
}
|
|
|
|
func krw(v string) market.Price {
|
|
return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}}
|
|
}
|
|
|
|
func validStartPaperRequest() *altv1.StartPaperTradingRequest {
|
|
return &altv1.StartPaperTradingRequest{
|
|
AccountId: "paper-1",
|
|
Spec: &altv1.BacktestRunSpec{
|
|
StrategyId: "strategy-v1",
|
|
Market: altv1.Market_MARKET_KR,
|
|
Timeframe: altv1.Timeframe_TIMEFRAME_DAILY,
|
|
FromUnixMs: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC).UnixMilli(),
|
|
ToUnixMs: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC).UnixMilli(),
|
|
},
|
|
StartingCash: &altv1.Price{
|
|
Currency: altv1.Currency_CURRENCY_KRW,
|
|
Amount: &altv1.Decimal{Value: "10000000"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func sampleState() papertrading.State {
|
|
return papertrading.State{
|
|
Run: backtest.Run{
|
|
ID: "paper-run-1",
|
|
Status: backtest.RunStatusSucceeded,
|
|
Spec: backtest.RunSpec{StrategyID: "strategy-v1", Market: market.MarketKR, Timeframe: market.TimeframeDaily},
|
|
},
|
|
Cash: krw("9998950"),
|
|
Positions: []backtest.Position{
|
|
{InstrumentID: "KRX:005930", Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, LastPrice: krw("1150")},
|
|
},
|
|
Fills: []backtest.Fill{
|
|
{InstrumentID: "KRX:005930", Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, Price: krw("1050"), Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC)},
|
|
},
|
|
EquityCurve: []backtest.EquityPoint{
|
|
{Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Equity: krw("10000100")},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestHandleStartPaperTradingSuccess(t *testing.T) {
|
|
paper := &fakePaperService{state: sampleState()}
|
|
deps := Deps{Paper: paper}
|
|
|
|
resp, err := handleStartPaperTrading(deps, validStartPaperRequest())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
st := resp.GetState()
|
|
if st.GetAccountId() != "paper-1" {
|
|
t.Errorf("account id mismatch: %q", st.GetAccountId())
|
|
}
|
|
if st.GetRun().GetStatus() != altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_SUCCEEDED {
|
|
t.Errorf("expected succeeded run status, got %v", st.GetRun().GetStatus())
|
|
}
|
|
if st.GetCash().GetAmount().GetValue() != "9998950" {
|
|
t.Errorf("cash mismatch: %q", st.GetCash().GetAmount().GetValue())
|
|
}
|
|
if len(st.GetPositions()) != 1 || st.GetPositions()[0].GetInstrumentId() != "KRX:005930" {
|
|
t.Errorf("positions mismatch: %+v", st.GetPositions())
|
|
}
|
|
if len(st.GetFills()) != 1 || st.GetFills()[0].GetPrice().GetAmount().GetValue() != "1050" {
|
|
t.Errorf("fills mismatch: %+v", st.GetFills())
|
|
}
|
|
if len(st.GetEquityCurve()) != 1 {
|
|
t.Errorf("equity curve mismatch: %+v", st.GetEquityCurve())
|
|
}
|
|
if paper.gotStart.AccountID != "paper-1" || paper.gotStart.StartingCash.Amount.Value != "10000000" {
|
|
t.Errorf("service received wrong start request: %+v", paper.gotStart)
|
|
}
|
|
}
|
|
|
|
func TestHandleStartPaperTradingInvalidRequest(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{}}
|
|
|
|
// missing account_id
|
|
resp, err := handleStartPaperTrading(deps, &altv1.StartPaperTradingRequest{
|
|
Spec: validStartPaperRequest().GetSpec(),
|
|
StartingCash: validStartPaperRequest().GetStartingCash(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected typed validation response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
|
|
|
// missing starting cash
|
|
resp, err = handleStartPaperTrading(deps, &altv1.StartPaperTradingRequest{
|
|
AccountId: "paper-1",
|
|
Spec: validStartPaperRequest().GetSpec(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected typed validation response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
|
}
|
|
|
|
func TestHandleStartPaperTradingUnavailable(t *testing.T) {
|
|
resp, err := handleStartPaperTrading(Deps{}, validStartPaperRequest())
|
|
if err != nil {
|
|
t.Fatalf("expected typed unavailable response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
|
|
}
|
|
|
|
func TestHandleStartPaperTradingServiceError(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{startErr: errors.New("boom")}}
|
|
resp, err := handleStartPaperTrading(deps, validStartPaperRequest())
|
|
if err != nil {
|
|
t.Fatalf("expected typed internal response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInternal)
|
|
}
|
|
|
|
func TestHandleGetPaperTradingStateSuccess(t *testing.T) {
|
|
paper := &fakePaperService{state: sampleState()}
|
|
deps := Deps{Paper: paper}
|
|
|
|
resp, err := handleGetPaperTradingState(deps, &altv1.GetPaperTradingStateRequest{AccountId: "paper-1"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
if resp.GetState().GetAccountId() != "paper-1" {
|
|
t.Errorf("account id mismatch: %q", resp.GetState().GetAccountId())
|
|
}
|
|
if paper.gotAccount != "paper-1" {
|
|
t.Errorf("service received wrong account id: %q", paper.gotAccount)
|
|
}
|
|
}
|
|
|
|
func TestHandleGetPaperTradingStateNotFound(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{stateErr: papertrading.ErrAccountNotFound}}
|
|
resp, err := handleGetPaperTradingState(deps, &altv1.GetPaperTradingStateRequest{AccountId: "missing"})
|
|
if err != nil {
|
|
t.Fatalf("expected typed not_found response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorNotFound)
|
|
}
|
|
|
|
func TestHandleGetPaperTradingStateRequiresAccountID(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{}}
|
|
resp, err := handleGetPaperTradingState(deps, &altv1.GetPaperTradingStateRequest{})
|
|
if err != nil {
|
|
t.Fatalf("expected typed validation response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
|
}
|
|
|
|
func TestHandleGetPaperTradingStateUnavailable(t *testing.T) {
|
|
resp, err := handleGetPaperTradingState(Deps{}, &altv1.GetPaperTradingStateRequest{AccountId: "paper-1"})
|
|
if err != nil {
|
|
t.Fatalf("expected typed unavailable response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
|
|
}
|
|
|
|
func TestPaperHandlersCoverAllRequests(t *testing.T) {
|
|
registered := make(map[string]bool)
|
|
for _, h := range paperHandlers(Deps{}) {
|
|
if h.requestType == "" {
|
|
t.Error("handler has empty request type")
|
|
}
|
|
if h.register == nil {
|
|
t.Errorf("handler %q has nil register", h.requestType)
|
|
}
|
|
registered[h.requestType] = true
|
|
}
|
|
for _, req := range []string{"alt.v1.StartPaperTradingRequest", "alt.v1.GetPaperTradingStateRequest"} {
|
|
if !registered[req] {
|
|
t.Errorf("missing handler for %q", req)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWorkerPaperSocketUnavailableReturnsTypedError(t *testing.T) {
|
|
client := startBacktestWorkerTestClient(t, Deps{})
|
|
|
|
resp, err := protoSocket.SendRequestTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](
|
|
&client.Communicator,
|
|
validStartPaperRequest(),
|
|
500*time.Millisecond,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("request should return typed error response without timeout: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
|
|
}
|
|
|
|
func TestWorkerPaperSocketStartRoundTrip(t *testing.T) {
|
|
client := startBacktestWorkerTestClient(t, Deps{Paper: &fakePaperService{state: sampleState()}})
|
|
|
|
resp, err := protoSocket.SendRequestTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](
|
|
&client.Communicator,
|
|
validStartPaperRequest(),
|
|
time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("round trip failed: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
if resp.GetState().GetAccountId() != "paper-1" {
|
|
t.Errorf("account id mismatch: %q", resp.GetState().GetAccountId())
|
|
}
|
|
}
|