alt/services/worker/internal/papertrading/service_test.go
toki daa29d6807 feat: paper trading command workflow completed (G08, G09)
- 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
2026-06-06 11:36:50 +09:00

432 lines
15 KiB
Go

package papertrading
import (
"context"
"errors"
"testing"
"time"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
func serviceTestRequest() StartRequest {
return StartRequest{
AccountID: "paper-acct-1",
Spec: backtest.RunSpec{
StrategyID: "test-simple-buy",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
To: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
},
StartingCash: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}},
}
}
func newTestService() *Service {
bars := makeBars(nil)
engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: simpleBuyStrategy{id: "test-simple-buy"}})
fixed := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
return NewService(engine, func() time.Time { return fixed })
}
func TestServiceStartRecordsState(t *testing.T) {
svc := newTestService()
state, err := svc.StartPaperTrading(context.Background(), serviceTestRequest())
if err != nil {
t.Fatalf("start failed: %v", err)
}
if state.Run.Status != backtest.RunStatusSucceeded {
t.Errorf("expected succeeded run status, got %q", state.Run.Status)
}
if state.Run.ID == "" {
t.Error("expected a non-empty run id")
}
// simpleBuyStrategy buys 1 unit on day 1, fills on day 2 at open 1050.
if len(state.Fills) != 1 {
t.Fatalf("expected 1 fill, got %d", len(state.Fills))
}
if state.Cash.Amount.Value != "9998950" {
t.Errorf("expected cash 9998950, got %s", state.Cash.Amount.Value)
}
if len(state.Positions) != 1 {
t.Errorf("expected 1 position, got %d", len(state.Positions))
}
}
func TestServiceGetStateReturnsRecorded(t *testing.T) {
svc := newTestService()
if _, err := svc.StartPaperTrading(context.Background(), serviceTestRequest()); err != nil {
t.Fatalf("start failed: %v", err)
}
state, err := svc.GetPaperTradingState(context.Background(), "paper-acct-1")
if err != nil {
t.Fatalf("get state failed: %v", err)
}
if state.Run.Status != backtest.RunStatusSucceeded {
t.Errorf("expected succeeded run status, got %q", state.Run.Status)
}
}
func TestServiceGetStateNotFound(t *testing.T) {
svc := newTestService()
_, err := svc.GetPaperTradingState(context.Background(), "never-started")
if !errors.Is(err, ErrAccountNotFound) {
t.Fatalf("expected ErrAccountNotFound, got %v", err)
}
}
func TestServiceStartRequiresAccountID(t *testing.T) {
svc := newTestService()
req := serviceTestRequest()
req.AccountID = ""
if _, err := svc.StartPaperTrading(context.Background(), req); err == nil {
t.Fatal("expected error for empty account id")
}
}
// --- order lifecycle ---
func krwPrice(v string) market.Price {
return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}}
}
func qty(v string) market.Quantity {
return market.Quantity{Amount: market.Decimal{Value: v}}
}
// startedService starts the standard test account so order lifecycle tests run
// against a seeded order book (cash 9998950, 1 unit of KRX:005930).
func startedService(t *testing.T) *Service {
t.Helper()
svc := newTestService()
if _, err := svc.StartPaperTrading(context.Background(), serviceTestRequest()); err != nil {
t.Fatalf("start failed: %v", err)
}
return svc
}
func buyMarketIntent(qtyVal string) backtest.OrderIntent {
return backtest.OrderIntent{
InstrumentID: "KRX:005930",
Side: backtest.OrderSideBuy,
Quantity: qty(qtyVal),
Type: backtest.OrderTypeMarket,
}
}
func TestServiceSubmitOrderRecordsPending(t *testing.T) {
svc := startedService(t)
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{
AccountID: "paper-acct-1",
Intent: buyMarketIntent("1"),
})
if err != nil {
t.Fatalf("submit failed: %v", err)
}
if order.Status != PaperOrderStatusPending {
t.Errorf("expected pending status, got %q", order.Status)
}
if order.OrderID != "paper-order-paper-acct-1-1" {
t.Errorf("unexpected order id %q", order.OrderID)
}
if order.Fill != nil {
t.Errorf("pending order must not carry a fill: %+v", order.Fill)
}
}
func TestServiceSubmitOrderRequiresStartedAccount(t *testing.T) {
svc := newTestService()
_, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{
AccountID: "never-started",
Intent: buyMarketIntent("1"),
})
if !errors.Is(err, ErrAccountNotFound) {
t.Fatalf("expected ErrAccountNotFound, got %v", err)
}
}
func TestServiceSubmitOrderRejectsInvalidIntent(t *testing.T) {
svc := startedService(t)
bad := buyMarketIntent("1")
bad.Side = "hold"
if _, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: bad}); err == nil {
t.Fatal("expected error for unsupported side")
}
}
func TestServiceFillMarketOrderUpdatesPortfolio(t *testing.T) {
svc := startedService(t)
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
if err != nil {
t.Fatalf("submit failed: %v", err)
}
filled, state, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000"))
if err != nil {
t.Fatalf("fill failed: %v", err)
}
if filled.Status != PaperOrderStatusFilled {
t.Fatalf("expected filled status, got %q (reason %q)", filled.Status, filled.Reason)
}
if filled.Fill == nil || filled.Fill.Price.Amount.Value != "1000" {
t.Fatalf("expected fill at 1000, got %+v", filled.Fill)
}
// Buy 1 @ 1000 against seed cash 9998950 -> 9997950, position 1 + 1 = 2.
if state.Cash.Amount.Value != "9997950" {
t.Errorf("expected cash 9997950, got %s", state.Cash.Amount.Value)
}
if len(state.Positions) != 1 || state.Positions[0].Quantity.Amount.Value != "2" {
t.Errorf("expected position qty 2, got %+v", state.Positions)
}
// Seed run fill (1) plus the manual fill (1).
if len(state.Fills) != 2 {
t.Errorf("expected 2 cumulative fills, got %d", len(state.Fills))
}
}
func TestServiceFillLimitOrderUsesLimitPrice(t *testing.T) {
svc := startedService(t)
intent := backtest.OrderIntent{
InstrumentID: "KRX:005930",
Side: backtest.OrderSideBuy,
Quantity: qty("1"),
Type: backtest.OrderTypeLimit,
LimitPrice: krwPrice("900"),
}
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: intent})
if err != nil {
t.Fatalf("submit failed: %v", err)
}
// A limit order executes at its own limit price; fill_price is irrelevant.
filled, state, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, market.Price{})
if err != nil {
t.Fatalf("fill failed: %v", err)
}
if filled.Status != PaperOrderStatusFilled || filled.Fill.Price.Amount.Value != "900" {
t.Fatalf("expected fill at limit 900, got status %q fill %+v", filled.Status, filled.Fill)
}
if state.Cash.Amount.Value != "9998050" {
t.Errorf("expected cash 9998050, got %s", state.Cash.Amount.Value)
}
}
func TestServiceFillMarketOrderRequiresFillPrice(t *testing.T) {
svc := startedService(t)
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
if err != nil {
t.Fatalf("submit failed: %v", err)
}
_, _, err = svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, market.Price{})
if !errors.Is(err, ErrFillPriceRequired) {
t.Fatalf("expected ErrFillPriceRequired, got %v", err)
}
}
func TestServiceFillRejectedByRisk(t *testing.T) {
svc := startedService(t)
// Sell 5 units while the seeded position holds only 1; short selling disabled.
intent := backtest.OrderIntent{
InstrumentID: "KRX:005930",
Side: backtest.OrderSideSell,
Quantity: qty("5"),
Type: backtest.OrderTypeMarket,
}
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: intent})
if err != nil {
t.Fatalf("submit failed: %v", err)
}
rejected, state, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1200"))
if err != nil {
t.Fatalf("fill should not error on a risk denial: %v", err)
}
if rejected.Status != PaperOrderStatusRejected {
t.Fatalf("expected rejected status, got %q", rejected.Status)
}
if rejected.Reason == "" {
t.Error("expected a non-empty rejection reason")
}
// Risk denial leaves the portfolio untouched.
if state.Cash.Amount.Value != "9998950" {
t.Errorf("expected cash unchanged at 9998950, got %s", state.Cash.Amount.Value)
}
}
func TestServiceFillRejectedByInsufficientCash(t *testing.T) {
svc := startedService(t)
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("100000")})
if err != nil {
t.Fatalf("submit failed: %v", err)
}
rejected, state, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000"))
if err != nil {
t.Fatalf("fill should not error on a portfolio denial: %v", err)
}
if rejected.Status != PaperOrderStatusRejected {
t.Fatalf("expected rejected status, got %q", rejected.Status)
}
if state.Cash.Amount.Value != "9998950" {
t.Errorf("expected cash unchanged at 9998950, got %s", state.Cash.Amount.Value)
}
}
func TestServiceCancelTransitionsPendingOrder(t *testing.T) {
svc := startedService(t)
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
if err != nil {
t.Fatalf("submit failed: %v", err)
}
canceled, err := svc.CancelPaperOrder(context.Background(), "paper-acct-1", order.OrderID)
if err != nil {
t.Fatalf("cancel failed: %v", err)
}
if canceled.Status != PaperOrderStatusCanceled {
t.Errorf("expected canceled status, got %q", canceled.Status)
}
// Filling a canceled order is an invalid transition.
if _, _, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000")); !errors.Is(err, ErrOrderNotPending) {
t.Fatalf("expected ErrOrderNotPending, got %v", err)
}
}
func TestServiceCancelMissingOrder(t *testing.T) {
svc := startedService(t)
// Started account but no such order id.
if _, err := svc.CancelPaperOrder(context.Background(), "paper-acct-1", "paper-order-paper-acct-1-99"); !errors.Is(err, ErrOrderNotFound) {
t.Fatalf("expected ErrOrderNotFound, got %v", err)
}
// Never-started account.
if _, err := svc.CancelPaperOrder(context.Background(), "ghost", "any"); !errors.Is(err, ErrAccountNotFound) {
t.Fatalf("expected ErrAccountNotFound, got %v", err)
}
}
func TestServiceGetStateReflectsOrderBook(t *testing.T) {
svc := startedService(t)
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
if err != nil {
t.Fatalf("submit failed: %v", err)
}
if _, _, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000")); err != nil {
t.Fatalf("fill failed: %v", err)
}
// After a manual fill, the account view reflects the live order book, not the
// stale terminal run state.
state, err := svc.GetPaperTradingState(context.Background(), "paper-acct-1")
if err != nil {
t.Fatalf("get state failed: %v", err)
}
if state.Cash.Amount.Value != "9997950" {
t.Errorf("expected live cash 9997950, got %s", state.Cash.Amount.Value)
}
}
// TestServiceStartClearsOrderBook is the restart regression: a second start of
// the same account must discard the prior order book so state and order ids are
// seeded from the new run, not the stale manual portfolio/sequence.
func TestServiceStartClearsOrderBook(t *testing.T) {
svc := startedService(t)
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
if err != nil {
t.Fatalf("submit failed: %v", err)
}
if order.OrderID != "paper-order-paper-acct-1-1" {
t.Fatalf("unexpected first order id %q", order.OrderID)
}
if _, _, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000")); err != nil {
t.Fatalf("fill failed: %v", err)
}
// Pre-restart the live book is exposed (cash mutated by the manual fill).
pre, err := svc.GetPaperTradingState(context.Background(), "paper-acct-1")
if err != nil {
t.Fatalf("pre-restart state failed: %v", err)
}
if pre.Cash.Amount.Value != "9997950" {
t.Fatalf("expected pre-restart live cash 9997950, got %s", pre.Cash.Amount.Value)
}
// Restart the same account.
if _, err := svc.StartPaperTrading(context.Background(), serviceTestRequest()); err != nil {
t.Fatalf("restart failed: %v", err)
}
// State must now reflect the fresh run seed, not the stale order book.
post, err := svc.GetPaperTradingState(context.Background(), "paper-acct-1")
if err != nil {
t.Fatalf("post-restart state failed: %v", err)
}
if post.Cash.Amount.Value != "9998950" {
t.Errorf("expected post-restart run-seed cash 9998950, got %s", post.Cash.Amount.Value)
}
// The next order starts from a fresh book sequence.
next, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
if err != nil {
t.Fatalf("post-restart submit failed: %v", err)
}
if next.OrderID != "paper-order-paper-acct-1-1" {
t.Errorf("expected fresh sequence paper-order-paper-acct-1-1, got %q", next.OrderID)
}
}
func TestServiceSubmitRejectsInvalidQuantity(t *testing.T) {
svc := startedService(t)
for _, q := range []string{"0", "-1", "abc", " "} {
_, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent(q)})
if !errors.Is(err, ErrInvalidOrderInput) {
t.Errorf("quantity %q: expected ErrInvalidOrderInput, got %v", q, err)
}
}
}
func TestServiceSubmitRejectsInvalidLimitPrice(t *testing.T) {
svc := startedService(t)
for _, p := range []string{"-1", "abc"} {
intent := backtest.OrderIntent{
InstrumentID: "KRX:005930",
Side: backtest.OrderSideBuy,
Quantity: qty("1"),
Type: backtest.OrderTypeLimit,
LimitPrice: krwPrice(p),
}
_, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: intent})
if !errors.Is(err, ErrInvalidOrderInput) {
t.Errorf("limit price %q: expected ErrInvalidOrderInput, got %v", p, err)
}
}
}
func TestServiceFillRejectsInvalidFillPrice(t *testing.T) {
svc := startedService(t)
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
if err != nil {
t.Fatalf("submit failed: %v", err)
}
for _, p := range []string{"-1", "abc"} {
// A malformed/negative fill price is rejected before any portfolio change,
// so the order stays pending and can be retried with a valid price.
_, _, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice(p))
if !errors.Is(err, ErrInvalidOrderInput) {
t.Errorf("fill price %q: expected ErrInvalidOrderInput, got %v", p, err)
}
}
// A valid fill still succeeds afterwards (order was left pending).
filled, _, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000"))
if err != nil {
t.Fatalf("valid fill failed: %v", err)
}
if filled.Status != PaperOrderStatusFilled {
t.Errorf("expected filled after valid retry, got %q", filled.Status)
}
}