- 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
432 lines
15 KiB
Go
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)
|
|
}
|
|
}
|