- Archive completed subtasks (02+01_risk_command, 03+02_order_lifecycle) - Add paper_order_lifecycle test data and expected output - Update paper trading proto and regenerate code (Dart, Go) - Fix order lifecycle handling in CLI operator, API socket, worker socket - Update parser maps across CLI, API, and worker services - Update backtest and paper trading tests
597 lines
21 KiB
Go
597 lines
21 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
|
|
|
|
// Order lifecycle captures and canned results.
|
|
gotSubmit papertrading.SubmitOrderRequest
|
|
gotCancelOrder string
|
|
gotFillOrder string
|
|
gotFillPrice market.Price
|
|
order papertrading.PaperOrder
|
|
submitErr error
|
|
cancelErr error
|
|
fillErr 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 (f *fakePaperService) SubmitPaperOrder(ctx context.Context, req papertrading.SubmitOrderRequest) (papertrading.PaperOrder, error) {
|
|
f.gotSubmit = req
|
|
if f.submitErr != nil {
|
|
return papertrading.PaperOrder{}, f.submitErr
|
|
}
|
|
return f.order, nil
|
|
}
|
|
|
|
func (f *fakePaperService) CancelPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string) (papertrading.PaperOrder, error) {
|
|
f.gotAccount = accountID
|
|
f.gotCancelOrder = orderID
|
|
if f.cancelErr != nil {
|
|
return papertrading.PaperOrder{}, f.cancelErr
|
|
}
|
|
return f.order, nil
|
|
}
|
|
|
|
func (f *fakePaperService) FillPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string, fillPrice market.Price) (papertrading.PaperOrder, papertrading.State, error) {
|
|
f.gotAccount = accountID
|
|
f.gotFillOrder = orderID
|
|
f.gotFillPrice = fillPrice
|
|
if f.fillErr != nil {
|
|
return papertrading.PaperOrder{}, papertrading.State{}, f.fillErr
|
|
}
|
|
return f.order, f.state, nil
|
|
}
|
|
|
|
func sampleOrder() papertrading.PaperOrder {
|
|
return papertrading.PaperOrder{
|
|
OrderID: "paper-order-paper-1-1",
|
|
AccountID: "paper-1",
|
|
Intent: backtest.OrderIntent{
|
|
InstrumentID: "KRX:005930",
|
|
Side: backtest.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
|
|
Type: backtest.OrderTypeMarket,
|
|
},
|
|
Status: papertrading.PaperOrderStatusPending,
|
|
}
|
|
}
|
|
|
|
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")},
|
|
},
|
|
Rejected: []papertrading.RejectedOrder{
|
|
{
|
|
Order: backtest.OrderIntent{
|
|
InstrumentID: "KRX:000660",
|
|
Side: backtest.OrderSideSell,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}},
|
|
},
|
|
Reason: "insufficient position quantity for sell order",
|
|
BarTime: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
|
|
Instrument: "KRX:000660",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
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 len(st.GetRiskRejections()) != 1 {
|
|
t.Fatalf("risk rejections mismatch: %+v", st.GetRiskRejections())
|
|
}
|
|
rej := st.GetRiskRejections()[0]
|
|
if rej.GetInstrumentId() != "KRX:000660" || rej.GetSide() != "sell" {
|
|
t.Errorf("rejection instrument/side mismatch: %+v", rej)
|
|
}
|
|
if rej.GetReason() != "insufficient position quantity for sell order" {
|
|
t.Errorf("rejection reason mismatch: %q", rej.GetReason())
|
|
}
|
|
if rej.GetQuantity().GetAmount().GetValue() != "2" {
|
|
t.Errorf("rejection quantity mismatch: %q", rej.GetQuantity().GetAmount().GetValue())
|
|
}
|
|
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 TestHandleSubmitPaperOrderSuccess(t *testing.T) {
|
|
paper := &fakePaperService{order: sampleOrder()}
|
|
deps := Deps{Paper: paper}
|
|
|
|
resp, err := handleSubmitPaperOrder(deps, &altv1.SubmitPaperOrderRequest{
|
|
AccountId: "paper-1",
|
|
InstrumentId: "KRX:005930",
|
|
Side: "buy",
|
|
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}},
|
|
Type: "market",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
if resp.GetOrder().GetOrderId() != "paper-order-paper-1-1" {
|
|
t.Errorf("order id mismatch: %q", resp.GetOrder().GetOrderId())
|
|
}
|
|
if resp.GetOrder().GetStatus() != "pending" {
|
|
t.Errorf("expected pending status, got %q", resp.GetOrder().GetStatus())
|
|
}
|
|
if paper.gotSubmit.Intent.InstrumentID != "KRX:005930" || paper.gotSubmit.Intent.Side != backtest.OrderSideBuy {
|
|
t.Errorf("service received wrong intent: %+v", paper.gotSubmit.Intent)
|
|
}
|
|
}
|
|
|
|
func TestHandleSubmitPaperOrderInvalidRequest(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{order: sampleOrder()}}
|
|
|
|
cases := []*altv1.SubmitPaperOrderRequest{
|
|
{InstrumentId: "KRX:005930", Side: "buy", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}}, // missing account
|
|
{AccountId: "paper-1", Side: "buy", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}}, // missing instrument
|
|
{AccountId: "paper-1", InstrumentId: "KRX:005930", Side: "hold", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}}, // bad side
|
|
{AccountId: "paper-1", InstrumentId: "KRX:005930", Side: "buy"}, // missing quantity
|
|
{AccountId: "paper-1", InstrumentId: "KRX:005930", Side: "buy", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}, Type: "limit"}, // limit without price
|
|
}
|
|
for i, req := range cases {
|
|
resp, err := handleSubmitPaperOrder(deps, req)
|
|
if err != nil {
|
|
t.Fatalf("case %d: expected typed validation response, got error: %v", i, err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
|
}
|
|
}
|
|
|
|
func TestHandleSubmitPaperOrderInvalidDecimal(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{order: sampleOrder()}}
|
|
base := func() *altv1.SubmitPaperOrderRequest {
|
|
return &altv1.SubmitPaperOrderRequest{
|
|
AccountId: "paper-1",
|
|
InstrumentId: "KRX:005930",
|
|
Side: "buy",
|
|
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}},
|
|
Type: "market",
|
|
}
|
|
}
|
|
|
|
// Non-numeric, zero, and negative quantities are rejected at the socket
|
|
// boundary before reaching the service.
|
|
for _, q := range []string{"0", "-1", "abc"} {
|
|
req := base()
|
|
req.Quantity = &altv1.Quantity{Amount: &altv1.Decimal{Value: q}}
|
|
resp, err := handleSubmitPaperOrder(deps, req)
|
|
if err != nil {
|
|
t.Fatalf("quantity %q: unexpected error: %v", q, err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
|
}
|
|
|
|
// Malformed and negative limit prices are rejected too.
|
|
for _, p := range []string{"-1", "abc"} {
|
|
req := base()
|
|
req.Type = "limit"
|
|
req.LimitPrice = &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: p}}
|
|
resp, err := handleSubmitPaperOrder(deps, req)
|
|
if err != nil {
|
|
t.Fatalf("limit price %q: unexpected error: %v", p, err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
|
}
|
|
}
|
|
|
|
func TestHandleFillPaperOrderInvalidFillPrice(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{order: sampleOrder(), state: sampleState()}}
|
|
for _, p := range []string{"-1", "abc"} {
|
|
resp, err := handleFillPaperOrder(deps, &altv1.FillPaperOrderRequest{
|
|
AccountId: "paper-1",
|
|
OrderId: "paper-order-paper-1-1",
|
|
FillPrice: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: p}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("fill price %q: unexpected error: %v", p, err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
|
}
|
|
}
|
|
|
|
func TestHandleSubmitPaperOrderUnavailable(t *testing.T) {
|
|
resp, err := handleSubmitPaperOrder(Deps{}, &altv1.SubmitPaperOrderRequest{
|
|
AccountId: "paper-1",
|
|
InstrumentId: "KRX:005930",
|
|
Side: "buy",
|
|
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected typed unavailable response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
|
|
}
|
|
|
|
func TestHandleSubmitPaperOrderAccountNotFound(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{submitErr: papertrading.ErrAccountNotFound}}
|
|
resp, err := handleSubmitPaperOrder(deps, &altv1.SubmitPaperOrderRequest{
|
|
AccountId: "missing",
|
|
InstrumentId: "KRX:005930",
|
|
Side: "buy",
|
|
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected typed not_found response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorNotFound)
|
|
}
|
|
|
|
func TestHandleCancelPaperOrderSuccess(t *testing.T) {
|
|
order := sampleOrder()
|
|
order.Status = papertrading.PaperOrderStatusCanceled
|
|
paper := &fakePaperService{order: order}
|
|
deps := Deps{Paper: paper}
|
|
|
|
resp, err := handleCancelPaperOrder(deps, &altv1.CancelPaperOrderRequest{AccountId: "paper-1", OrderId: "paper-order-paper-1-1"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
if resp.GetOrder().GetStatus() != "canceled" {
|
|
t.Errorf("expected canceled status, got %q", resp.GetOrder().GetStatus())
|
|
}
|
|
if paper.gotCancelOrder != "paper-order-paper-1-1" {
|
|
t.Errorf("service received wrong order id: %q", paper.gotCancelOrder)
|
|
}
|
|
}
|
|
|
|
func TestHandleCancelPaperOrderValidation(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{order: sampleOrder()}}
|
|
for _, req := range []*altv1.CancelPaperOrderRequest{
|
|
{OrderId: "o-1"}, // missing account
|
|
{AccountId: "paper-1"}, // missing order id
|
|
} {
|
|
resp, err := handleCancelPaperOrder(deps, req)
|
|
if err != nil {
|
|
t.Fatalf("expected typed validation response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
|
}
|
|
}
|
|
|
|
func TestHandleCancelPaperOrderNotPending(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{cancelErr: papertrading.ErrOrderNotPending}}
|
|
resp, err := handleCancelPaperOrder(deps, &altv1.CancelPaperOrderRequest{AccountId: "paper-1", OrderId: "o-1"})
|
|
if err != nil {
|
|
t.Fatalf("expected typed invalid_request response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
|
}
|
|
|
|
func TestHandleCancelPaperOrderNotFound(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{cancelErr: papertrading.ErrOrderNotFound}}
|
|
resp, err := handleCancelPaperOrder(deps, &altv1.CancelPaperOrderRequest{AccountId: "paper-1", OrderId: "missing"})
|
|
if err != nil {
|
|
t.Fatalf("expected typed not_found response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorNotFound)
|
|
}
|
|
|
|
func TestHandleFillPaperOrderSuccess(t *testing.T) {
|
|
order := sampleOrder()
|
|
order.Status = papertrading.PaperOrderStatusFilled
|
|
order.Fill = &backtest.Fill{
|
|
InstrumentID: "KRX:005930",
|
|
Side: backtest.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
|
|
Price: krw("1000"),
|
|
Timestamp: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
|
|
}
|
|
paper := &fakePaperService{order: order, state: sampleState()}
|
|
deps := Deps{Paper: paper}
|
|
|
|
resp, err := handleFillPaperOrder(deps, &altv1.FillPaperOrderRequest{
|
|
AccountId: "paper-1",
|
|
OrderId: "paper-order-paper-1-1",
|
|
FillPrice: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "1000"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
|
}
|
|
if resp.GetOrder().GetStatus() != "filled" {
|
|
t.Errorf("expected filled status, got %q", resp.GetOrder().GetStatus())
|
|
}
|
|
if resp.GetOrder().GetFill().GetPrice().GetAmount().GetValue() != "1000" {
|
|
t.Errorf("fill price mismatch: %q", resp.GetOrder().GetFill().GetPrice().GetAmount().GetValue())
|
|
}
|
|
if resp.GetState().GetAccountId() != "paper-1" {
|
|
t.Errorf("expected state snapshot in response, got %+v", resp.GetState())
|
|
}
|
|
if paper.gotFillPrice.Amount.Value != "1000" {
|
|
t.Errorf("service received wrong fill price: %+v", paper.gotFillPrice)
|
|
}
|
|
}
|
|
|
|
func TestHandleFillPaperOrderRejectedIsTypedSuccess(t *testing.T) {
|
|
order := sampleOrder()
|
|
order.Status = papertrading.PaperOrderStatusRejected
|
|
order.Reason = "insufficient position quantity for sell order"
|
|
paper := &fakePaperService{order: order, state: sampleState()}
|
|
deps := Deps{Paper: paper}
|
|
|
|
resp, err := handleFillPaperOrder(deps, &altv1.FillPaperOrderRequest{
|
|
AccountId: "paper-1",
|
|
OrderId: "paper-order-paper-1-1",
|
|
FillPrice: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "1000"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// A business rejection is a successful response carrying status=rejected.
|
|
if resp.GetError() != nil {
|
|
t.Fatalf("rejection must not surface as a typed error: %+v", resp.GetError())
|
|
}
|
|
if resp.GetOrder().GetStatus() != "rejected" || resp.GetOrder().GetReason() == "" {
|
|
t.Errorf("expected rejected status with reason, got %+v", resp.GetOrder())
|
|
}
|
|
}
|
|
|
|
func TestHandleFillPaperOrderFillPriceRequired(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{fillErr: papertrading.ErrFillPriceRequired}}
|
|
resp, err := handleFillPaperOrder(deps, &altv1.FillPaperOrderRequest{AccountId: "paper-1", OrderId: "o-1"})
|
|
if err != nil {
|
|
t.Fatalf("expected typed invalid_request response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
|
}
|
|
|
|
func TestHandleFillPaperOrderValidation(t *testing.T) {
|
|
deps := Deps{Paper: &fakePaperService{order: sampleOrder()}}
|
|
for _, req := range []*altv1.FillPaperOrderRequest{
|
|
{OrderId: "o-1"}, // missing account
|
|
{AccountId: "paper-1"}, // missing order id
|
|
} {
|
|
resp, err := handleFillPaperOrder(deps, req)
|
|
if err != nil {
|
|
t.Fatalf("expected typed validation response, got error: %v", err)
|
|
}
|
|
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
|
}
|
|
}
|
|
|
|
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",
|
|
"alt.v1.SubmitPaperOrderRequest",
|
|
"alt.v1.CancelPaperOrderRequest",
|
|
"alt.v1.FillPaperOrderRequest",
|
|
} {
|
|
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())
|
|
}
|
|
}
|