alt/services/worker/internal/socket/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

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())
}
}