package execution import ( "context" "errors" "strings" "testing" "time" "github.com/shopspring/decimal" "overnight-trading-bot/internal/domain" "overnight-trading-bot/internal/risk" "overnight-trading-bot/internal/testutil" "overnight-trading-bot/internal/tinvest" ) type fixedClock struct { now time.Time } func (c *fixedClock) Now() time.Time { return c.now } func (c *fixedClock) Sleep(<-chan struct{}, time.Duration) bool { return true } func TestClientOrderIDIncludesAttempt(t *testing.T) { date := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) first := ClientOrderID(date, "uid:TRUR", domain.SideBuy, 1) second := ClientOrderID(date, "uid:TRUR", domain.SideBuy, 1) third := ClientOrderID(date, "uid:TRUR", domain.SideBuy, 2) if first != second { t.Fatalf("client order id is not deterministic: %s != %s", first, second) } if first == third { t.Fatalf("attempt is not part of client order id: %s", first) } } func TestPlaceLimitSuppressesDuplicateSubmit(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() gateway := tinvest.NewFakeGateway() engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) order := domain.Order{ ClientOrderID: "order-1", AccountIDHash: "hash", InstrumentUID: "uid", TradeDate: tradeDate, Side: domain.SideBuy, OrderType: domain.OrderTypeLimit, LimitPrice: decimal.NewFromInt(100), QuantityLots: 1, Status: domain.OrderStatusNew, AttemptNo: 1, } first, err := engine.PlaceLimit(ctx, order) if err != nil { t.Fatal(err) } second, err := engine.PlaceLimit(ctx, order) if err != nil { t.Fatal(err) } if first.BrokerOrderID != second.BrokerOrderID { t.Fatalf("duplicate submit posted a new broker order: %s != %s", first.BrokerOrderID, second.BrokerOrderID) } if got := len(gateway.Orders); got != 1 { t.Fatalf("broker posts=%d, want 1", got) } sent, err := repo.GetFreeOrdersSent(ctx, tradeDate, "uid") if err != nil { t.Fatal(err) } if sent != 1 { t.Fatalf("free order counter=%d, want 1", sent) } } func TestPlaceEntryReservesFreeOrderBudgetAtomically(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() gateway := tinvest.NewFakeGateway() engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) instrument := domain.Instrument{ InstrumentUID: "uid", Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), FreeOrderLimitPerDay: 1, } book := domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) if _, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 1, book, 1, 1); err != nil { t.Fatal(err) } _, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 1, book, 1, 2) if !errors.Is(err, risk.ErrFreeOrderBudget) { t.Fatalf("expected free order budget error, got %v", err) } if got := len(gateway.Orders); got != 1 { t.Fatalf("broker orders=%d, want no second post", got) } } func TestRefreshPreservesLocalQuoteContext(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() gateway := tinvest.NewFakeGateway() engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) instrument := domain.Instrument{ InstrumentUID: "uid", Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), } book := domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } order, err := engine.PlaceEntry(ctx, "hash", instrument, time.Now().UTC(), 1, book, 1, 1) if err != nil { t.Fatal(err) } refreshed, err := engine.Refresh(ctx, order) if err != nil { t.Fatal(err) } if !strings.Contains(refreshed.RawStateJSON, "local_quote") || !strings.Contains(refreshed.RawStateJSON, `"mid":"100"`) { t.Fatalf("raw state lost local quote context: %s", refreshed.RawStateJSON) } } func TestMonitorOnceUsesInjectedClockForDeadline(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() gateway := tinvest.NewFakeGateway() engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) clock := &fixedClock{now: time.Date(2030, 1, 1, 10, 0, 0, 0, time.UTC)} engine.SetClock(clock) order, err := engine.PlaceLimit(ctx, domain.Order{ ClientOrderID: "clocked", AccountIDHash: "hash", InstrumentUID: "uid", TradeDate: clock.now, Side: domain.SideBuy, OrderType: domain.OrderTypeLimit, LimitPrice: decimal.NewFromInt(100), QuantityLots: 1, Status: domain.OrderStatusNew, AttemptNo: 1, }) if err != nil { t.Fatal(err) } if !order.CreatedAt.Equal(clock.now) { t.Fatalf("created_at=%s, want injected clock %s", order.CreatedAt, clock.now) } monitored, err := engine.MonitorOnce(ctx, order, MonitorConfig{ Deadline: clock.now.Add(time.Minute), PollInterval: time.Millisecond, MaxAttempts: 1, }) if err != nil { t.Fatal(err) } if monitored.Status == domain.OrderStatusExpired { t.Fatalf("order expired before injected deadline: %+v", monitored) } clock.now = clock.now.Add(time.Minute) monitored, err = engine.MonitorOnce(ctx, order, MonitorConfig{ Deadline: clock.now, PollInterval: time.Millisecond, MaxAttempts: 1, }) if err != nil { t.Fatal(err) } if monitored.Status != domain.OrderStatusExpired { t.Fatalf("status=%s, want EXPIRED at injected deadline", monitored.Status) } } func TestPaperPlaceEntryFillsOnlyWhenOrderBookCrosses(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() paper := tinvest.NewPaperGateway(nil) paper.Fake().OrderBooks["uid"] = domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } engine := NewEngine(domain.ModePaper, "account", paper, repo) tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) order, err := engine.PlaceEntry(ctx, "hash", domain.Instrument{ InstrumentUID: "uid", Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), }, tradeDate, 2, domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), }, 1, 1) if err != nil { t.Fatal(err) } if order.Status != domain.OrderStatusSent || order.FilledLots != 0 || order.BrokerOrderID == "" { t.Fatalf("paper order=%+v, want sent unfilled broker-like order", order) } paper.Fake().OrderBooks["uid"] = domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(100), QuantityLots: 1}}, ReceivedAt: time.Now().UTC(), } partial, err := engine.MonitorOnce(ctx, order, MonitorConfig{}) if err != nil { t.Fatal(err) } if partial.Status != domain.OrderStatusPartiallyFilled || partial.FilledLots != 1 { t.Fatalf("paper partial order=%+v, want 1 lot partial fill", partial) } paper.Fake().OrderBooks["uid"] = domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(100), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } filled, err := engine.MonitorOnce(ctx, partial, MonitorConfig{}) if err != nil { t.Fatal(err) } if filled.Status != domain.OrderStatusFilled || filled.FilledLots != 2 { t.Fatalf("paper filled order=%+v, want full fill", filled) } sent, err := repo.GetFreeOrdersSent(ctx, tradeDate, "uid") if err != nil { t.Fatal(err) } if sent != 1 { t.Fatalf("free order counter=%d, want 1", sent) } } func TestCancelCountsAsFreeOrderWhenPolicyEnabled(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() gateway := tinvest.NewFakeGateway() engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) engine.SetFreeOrderCountPolicy(FreeOrderPolicyCancelCounts) tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) order, err := engine.PlaceLimit(ctx, domain.Order{ ClientOrderID: "order-1", AccountIDHash: "hash", InstrumentUID: "uid", TradeDate: tradeDate, Side: domain.SideBuy, OrderType: domain.OrderTypeLimit, LimitPrice: decimal.NewFromInt(100), QuantityLots: 1, Status: domain.OrderStatusNew, AttemptNo: 1, }) if err != nil { t.Fatal(err) } if err := engine.Cancel(ctx, order); err != nil { t.Fatal(err) } sent, err := repo.GetFreeOrdersSent(ctx, tradeDate, "uid") if err != nil { t.Fatal(err) } if sent != 2 { t.Fatalf("free order counter=%d, want submit+cancel", sent) } } func TestPlaceEntryRejectsStaleQuote(t *testing.T) { ctx := context.Background() engine := NewEngine(domain.ModeSandbox, "account", tinvest.NewFakeGateway(), testutil.NewMemoryRepository()) engine.SetMaxQuoteAge(time.Second) _, err := engine.PlaceEntry(ctx, "hash", domain.Instrument{ InstrumentUID: "uid", Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), }, time.Now().UTC(), 1, domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, ReceivedAt: time.Now().UTC().Add(-2 * time.Second), }, 1, 1) if err == nil { t.Fatal("expected stale quote error") } } func TestPlaceEntryRejectsStaleExchangeQuoteTime(t *testing.T) { ctx := context.Background() now := time.Date(2026, 6, 8, 18, 20, 0, 0, time.UTC) engine := NewEngine(domain.ModeSandbox, "account", tinvest.NewFakeGateway(), testutil.NewMemoryRepository()) engine.SetClock(&fixedClock{now: now}) engine.SetMaxQuoteAge(time.Second) _, err := engine.PlaceEntry(ctx, "hash", domain.Instrument{ InstrumentUID: "uid", Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), }, now, 1, domain.OrderBook{ InstrumentUID: "uid", Time: now.Add(-2 * time.Second), ReceivedAt: now, Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, }, 1, 1) if err == nil { t.Fatal("expected stale exchange quote timestamp error") } } func TestLiveReadonlyDoesNotPersistLocalOrder(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() engine := NewEngine(domain.ModeLiveReadonly, "account", tinvest.NewFakeGateway(), repo) _, err := engine.PlaceLimit(ctx, domain.Order{ ClientOrderID: "readonly-order", AccountIDHash: "hash", InstrumentUID: "uid", TradeDate: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC), Side: domain.SideBuy, OrderType: domain.OrderTypeLimit, LimitPrice: decimal.NewFromInt(100), QuantityLots: 1, Status: domain.OrderStatusNew, AttemptNo: 1, }) if !errors.Is(err, ErrBrokerOrdersDisabled) { t.Fatalf("PlaceLimit err=%v, want ErrBrokerOrdersDisabled", err) } if len(repo.Orders) != 0 { t.Fatalf("readonly mode persisted orders: %+v", repo.Orders) } } func TestPlaceLimitModePolicy(t *testing.T) { tests := []struct { mode domain.Mode allowed bool }{ {mode: domain.ModeBacktest, allowed: false}, {mode: domain.ModePaper, allowed: true}, {mode: domain.ModeSandbox, allowed: true}, {mode: domain.ModeLiveReadonly, allowed: false}, {mode: domain.ModeLiveTrade, allowed: true}, } for _, tt := range tests { t.Run(string(tt.mode), func(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() engine := NewEngine(tt.mode, "account", tinvest.NewFakeGateway(), repo) _, err := engine.PlaceLimit(ctx, domain.Order{ ClientOrderID: "order-" + string(tt.mode), AccountIDHash: "hash", InstrumentUID: "uid", TradeDate: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC), Side: domain.SideBuy, OrderType: domain.OrderTypeLimit, LimitPrice: decimal.NewFromInt(100), QuantityLots: 1, Status: domain.OrderStatusNew, AttemptNo: 1, }) if tt.allowed && err != nil { t.Fatalf("PlaceLimit err=%v, want allowed", err) } if !tt.allowed && !errors.Is(err, ErrBrokerOrdersDisabled) { t.Fatalf("PlaceLimit err=%v, want ErrBrokerOrdersDisabled", err) } if tt.allowed && len(repo.Orders) != 1 { t.Fatalf("orders=%+v, want one persisted order", repo.Orders) } if !tt.allowed && len(repo.Orders) != 0 { t.Fatalf("orders=%+v, want no persisted order", repo.Orders) } }) } } func TestMonitorUntilRepostsAndExpiresAtDeadline(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() gateway := tinvest.NewFakeGateway() engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) instrument := domain.Instrument{ InstrumentUID: "uid", Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), FreeOrderLimitPerDay: -1, } book := domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) order, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 3, book, 1, 1) if err != nil { t.Fatal(err) } monitored, err := engine.MonitorUntil(ctx, order, MonitorConfig{ Deadline: time.Now().Add(20 * time.Millisecond), PollInterval: time.Millisecond, MaxAttempts: 2, RepostAfter: time.Nanosecond, Instrument: instrument, ImproveTicks: 1, Quote: func(context.Context, string) (domain.OrderBook, error) { book.ReceivedAt = time.Now().UTC() return book, nil }, }) if err != nil { t.Fatal(err) } if monitored.Status != domain.OrderStatusExpired { t.Fatalf("status=%s, want EXPIRED", monitored.Status) } if got := len(gateway.Orders); got < 2 { t.Fatalf("broker orders=%d, want repost attempt", got) } sent, err := repo.GetFreeOrdersSent(ctx, tradeDate, "uid") if err != nil { t.Fatal(err) } if sent != 2 { t.Fatalf("free order counter=%d, want 2", sent) } } func TestMonitorOnceDoesNotRepostWhenCheckRejects(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() gateway := tinvest.NewFakeGateway() engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) instrument := domain.Instrument{ InstrumentUID: "uid", Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), FreeOrderLimitPerDay: -1, } book := domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) order, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 3, book, 1, 1) if err != nil { t.Fatal(err) } order.CreatedAt = time.Now().UTC().Add(-time.Minute) if err := repo.UpsertOrder(ctx, order); err != nil { t.Fatal(err) } if _, err := engine.MonitorOnce(ctx, order, MonitorConfig{ Deadline: time.Now().Add(time.Minute), PollInterval: time.Millisecond, MaxAttempts: 2, RepostAfter: time.Second, Instrument: instrument, ImproveTicks: 1, Quote: func(context.Context, string) (domain.OrderBook, error) { book.ReceivedAt = time.Now().UTC() return book, nil }, RepostCheck: func(context.Context, domain.Order, domain.Instrument, domain.OrderBook) error { return context.Canceled }, }); err != nil { t.Fatal(err) } if got := len(gateway.Orders); got != 1 { t.Fatalf("broker orders=%d, want no repost", got) } } func TestMonitorOnceRepostAccountsForFillsDuringCancel(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() gateway := newCancelFillGateway(2) engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) instrument := domain.Instrument{ InstrumentUID: "uid", Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), FreeOrderLimitPerDay: -1, } book := domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) order, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 5, book, 1, 1) if err != nil { t.Fatal(err) } order.CreatedAt = time.Now().UTC().Add(-time.Minute) if err := repo.UpsertOrder(ctx, order); err != nil { t.Fatal(err) } monitored, err := engine.MonitorOnce(ctx, order, MonitorConfig{ Deadline: time.Now().Add(time.Minute), PollInterval: time.Millisecond, MaxAttempts: 2, RepostAfter: time.Second, Instrument: instrument, ImproveTicks: 1, Quote: func(context.Context, string) (domain.OrderBook, error) { book.ReceivedAt = time.Now().UTC() return book, nil }, }) if err != nil { t.Fatal(err) } if monitored.FilledLots != 2 { t.Fatalf("aggregate filled lots=%d, want cancel fill 2", monitored.FilledLots) } if !strings.Contains(monitored.RawStateJSON, "fill_quotes") { t.Fatalf("fill quote snapshot was not recorded: %s", monitored.RawStateJSON) } if got := len(gateway.posted); got != 2 { t.Fatalf("broker orders=%d, want initial+repost", got) } if got := gateway.posted[1].QuantityLots; got != 3 { t.Fatalf("repost quantity lots=%d, want remaining 3", got) } } func TestMonitorOnceAggregatesRepostsAcrossTicks(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() gateway := newCancelFillGateway(2) engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) instrument := domain.Instrument{ InstrumentUID: "uid", Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), FreeOrderLimitPerDay: -1, } book := domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) order, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 5, book, 1, 1) if err != nil { t.Fatal(err) } order.CreatedAt = time.Now().UTC().Add(-time.Minute) if err := repo.UpsertOrder(ctx, order); err != nil { t.Fatal(err) } cfg := MonitorConfig{ Deadline: time.Now().Add(time.Minute), PollInterval: time.Millisecond, MaxAttempts: 3, RepostAfter: time.Second, Instrument: instrument, ImproveTicks: 1, Quote: func(context.Context, string) (domain.OrderBook, error) { book.ReceivedAt = time.Now().UTC() return book, nil }, } first, err := engine.MonitorOnce(ctx, order, cfg) if err != nil { t.Fatal(err) } if first.FilledLots != 2 { t.Fatalf("first aggregate filled lots=%d, want 2", first.FilledLots) } active, err := repo.ListActiveOrders(ctx, "hash") if err != nil { t.Fatal(err) } if len(active) != 1 { t.Fatalf("active orders=%+v, want reposted order", active) } next := active[0] next.CreatedAt = time.Now().UTC().Add(-time.Minute) if err := repo.UpsertOrder(ctx, next); err != nil { t.Fatal(err) } second, err := engine.MonitorOnce(ctx, next, cfg) if err != nil { t.Fatal(err) } if second.FilledLots != 4 { t.Fatalf("second aggregate filled lots=%d, want 4 across reposts", second.FilledLots) } if got := len(gateway.posted); got != 3 { t.Fatalf("broker orders=%d, want initial+two reposts", got) } if got := gateway.posted[2].QuantityLots; got != 1 { t.Fatalf("second repost quantity lots=%d, want remaining 1", got) } } func TestMonitorOnceKeepsCancelFillWhenRepostPostFails(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() gateway := newCancelFillGateway(2) gateway.failPostAfter = 1 engine := NewEngine(domain.ModeSandbox, "account", gateway, repo) instrument := domain.Instrument{ InstrumentUID: "uid", Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), FreeOrderLimitPerDay: -1, } book := domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) order, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 5, book, 1, 1) if err != nil { t.Fatal(err) } order.CreatedAt = time.Now().UTC().Add(-time.Minute) if err := repo.UpsertOrder(ctx, order); err != nil { t.Fatal(err) } monitored, err := engine.MonitorOnce(ctx, order, MonitorConfig{ Deadline: time.Now().Add(time.Minute), PollInterval: time.Millisecond, MaxAttempts: 2, RepostAfter: time.Second, Instrument: instrument, ImproveTicks: 1, Quote: func(context.Context, string) (domain.OrderBook, error) { book.ReceivedAt = time.Now().UTC() return book, nil }, }) if err == nil { t.Fatal("expected repost post error") } if monitored.FilledLots != 2 { t.Fatalf("aggregate filled lots=%d, want cancel fill 2 despite error", monitored.FilledLots) } } type cancelFillGateway struct { orders map[string]domain.Order posted []domain.Order fillLotsOnCancel int64 failPostAfter int } func newCancelFillGateway(fillLotsOnCancel int64) *cancelFillGateway { return &cancelFillGateway{ orders: make(map[string]domain.Order), fillLotsOnCancel: fillLotsOnCancel, } } func (g *cancelFillGateway) PostLimitOrder(_ context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error) { if g.failPostAfter > 0 && len(g.posted) >= g.failPostAfter { return domain.Order{}, errors.New("post failed") } now := time.Now().UTC() order := domain.Order{ ClientOrderID: clientOrderID, BrokerOrderID: "broker-" + clientOrderID, AccountIDHash: accountID, InstrumentUID: instrumentUID, Side: side, OrderType: domain.OrderTypeLimit, LimitPrice: price, QuantityLots: lots, Status: domain.OrderStatusSent, RawStateJSON: "{}", CreatedAt: now, UpdatedAt: now, } g.orders[order.BrokerOrderID] = order g.posted = append(g.posted, order) return order, nil } func (g *cancelFillGateway) CancelOrder(_ context.Context, _ string, orderID string) error { order, ok := g.orders[orderID] if !ok { return tinvest.ErrNotFound } fillLots := min(g.fillLotsOnCancel, order.QuantityLots) if fillLots > order.FilledLots { order.FilledLots = fillLots order.AvgFillPrice = order.LimitPrice } order.Status = domain.OrderStatusCancelled order.UpdatedAt = time.Now().UTC() g.orders[orderID] = order return nil } func (g *cancelFillGateway) GetOrderState(_ context.Context, _ string, orderID string) (domain.Order, error) { order, ok := g.orders[orderID] if !ok { return domain.Order{}, tinvest.ErrNotFound } return order, nil }