2026-06-07 21:01:40 +00:00
|
|
|
package execution
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/shopspring/decimal"
|
|
|
|
|
|
|
|
|
|
"overnight-trading-bot/internal/domain"
|
|
|
|
|
"overnight-trading-bot/internal/repository"
|
2026-06-07 21:51:20 +00:00
|
|
|
"overnight-trading-bot/internal/risk"
|
2026-06-08 09:03:37 +00:00
|
|
|
"overnight-trading-bot/internal/timeutil"
|
2026-06-07 21:01:40 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var ErrBrokerOrdersDisabled = errors.New("broker orders are disabled for current mode")
|
|
|
|
|
var ErrEmptyOrderBook = errors.New("order book has no usable bid/ask")
|
|
|
|
|
|
2026-06-08 07:36:52 +00:00
|
|
|
const (
|
|
|
|
|
FreeOrderPolicySubmitted = "submitted"
|
|
|
|
|
FreeOrderPolicyCancelCounts = "cancel_counts"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-07 21:01:40 +00:00
|
|
|
type Gateway interface {
|
|
|
|
|
PostLimitOrder(ctx context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error)
|
|
|
|
|
CancelOrder(ctx context.Context, accountID, orderID string) error
|
|
|
|
|
GetOrderState(ctx context.Context, accountID, orderID string) (domain.Order, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Engine struct {
|
2026-06-08 07:36:52 +00:00
|
|
|
mode domain.Mode
|
|
|
|
|
accountID string
|
|
|
|
|
gateway Gateway
|
|
|
|
|
store repository.Repository
|
|
|
|
|
maxQuoteAge time.Duration
|
|
|
|
|
freeOrderCountPolicy string
|
2026-06-08 09:03:37 +00:00
|
|
|
clock timeutil.Clock
|
2026-06-08 07:36:52 +00:00
|
|
|
mu sync.Map
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type MonitorConfig struct {
|
|
|
|
|
Deadline time.Time
|
|
|
|
|
PollInterval time.Duration
|
|
|
|
|
MaxAttempts int
|
|
|
|
|
RepostAfter time.Duration
|
|
|
|
|
Instrument domain.Instrument
|
|
|
|
|
ImproveTicks int
|
|
|
|
|
Quote func(ctx context.Context, instrumentUID string) (domain.OrderBook, error)
|
2026-06-08 07:05:01 +00:00
|
|
|
RepostCheck func(ctx context.Context, order domain.Order, instrument domain.Instrument, book domain.OrderBook) error
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-08 11:11:50 +00:00
|
|
|
type repostResult struct {
|
|
|
|
|
Current domain.Order
|
|
|
|
|
Changed bool
|
|
|
|
|
Cancelled domain.Order
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 21:01:40 +00:00
|
|
|
func NewEngine(mode domain.Mode, accountID string, gateway Gateway, store repository.Repository) Engine {
|
2026-06-08 09:03:37 +00:00
|
|
|
return Engine{
|
|
|
|
|
mode: mode,
|
|
|
|
|
accountID: accountID,
|
|
|
|
|
gateway: gateway,
|
|
|
|
|
store: store,
|
|
|
|
|
freeOrderCountPolicy: FreeOrderPolicySubmitted,
|
|
|
|
|
clock: timeutil.RealClock{},
|
|
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Engine) SetMaxQuoteAge(maxQuoteAge time.Duration) {
|
|
|
|
|
e.maxQuoteAge = maxQuoteAge
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 09:03:37 +00:00
|
|
|
func (e *Engine) SetClock(clock timeutil.Clock) {
|
|
|
|
|
if clock != nil {
|
|
|
|
|
e.clock = clock
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 07:36:52 +00:00
|
|
|
func (e *Engine) SetFreeOrderCountPolicy(policy string) {
|
|
|
|
|
switch policy {
|
|
|
|
|
case FreeOrderPolicyCancelCounts:
|
|
|
|
|
e.freeOrderCountPolicy = policy
|
|
|
|
|
default:
|
|
|
|
|
e.freeOrderCountPolicy = FreeOrderPolicySubmitted
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 21:01:40 +00:00
|
|
|
func (e *Engine) PlaceEntry(ctx context.Context, accountIDHash string, instrument domain.Instrument, tradeDate time.Time, lots int64, book domain.OrderBook, improveTicks int, attempt int) (domain.Order, error) {
|
|
|
|
|
if err := e.checkQuoteFresh(book); err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
bid, ask, err := bestBidAsk(book)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
price, err := LimitBuyPrice(bid, ask, instrument.MinPriceIncrement, improveTicks)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
return e.placeLimit(ctx, domain.Order{
|
2026-06-07 21:01:40 +00:00
|
|
|
ClientOrderID: ClientOrderID(tradeDate, instrument.InstrumentUID, domain.SideBuy, attempt),
|
|
|
|
|
AccountIDHash: accountIDHash,
|
|
|
|
|
InstrumentUID: instrument.InstrumentUID,
|
|
|
|
|
TradeDate: tradeDate,
|
|
|
|
|
Side: domain.SideBuy,
|
|
|
|
|
OrderType: domain.OrderTypeLimit,
|
|
|
|
|
LimitPrice: price,
|
|
|
|
|
QuantityLots: lots,
|
|
|
|
|
Status: domain.OrderStatusNew,
|
|
|
|
|
AttemptNo: attempt,
|
|
|
|
|
RawStateJSON: "{}",
|
2026-06-08 09:03:37 +00:00
|
|
|
}, instrument.FreeOrderLimitPerDay)
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Engine) PlaceExit(ctx context.Context, accountIDHash string, instrument domain.Instrument, tradeDate time.Time, lots int64, book domain.OrderBook, improveTicks int, attempt int) (domain.Order, error) {
|
|
|
|
|
if err := e.checkQuoteFresh(book); err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
bid, ask, err := bestBidAsk(book)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
price, err := LimitSellPrice(bid, ask, instrument.MinPriceIncrement, improveTicks)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
return e.placeLimit(ctx, domain.Order{
|
2026-06-07 21:01:40 +00:00
|
|
|
ClientOrderID: ClientOrderID(tradeDate, instrument.InstrumentUID, domain.SideSell, attempt),
|
|
|
|
|
AccountIDHash: accountIDHash,
|
|
|
|
|
InstrumentUID: instrument.InstrumentUID,
|
|
|
|
|
TradeDate: tradeDate,
|
|
|
|
|
Side: domain.SideSell,
|
|
|
|
|
OrderType: domain.OrderTypeLimit,
|
|
|
|
|
LimitPrice: price,
|
|
|
|
|
QuantityLots: lots,
|
|
|
|
|
Status: domain.OrderStatusNew,
|
|
|
|
|
AttemptNo: attempt,
|
|
|
|
|
RawStateJSON: "{}",
|
2026-06-08 09:03:37 +00:00
|
|
|
}, instrument.FreeOrderLimitPerDay)
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Order, error) {
|
2026-06-08 09:03:37 +00:00
|
|
|
return e.placeLimit(ctx, order, 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Engine) placeLimit(ctx context.Context, order domain.Order, freeOrderLimit int) (domain.Order, error) {
|
2026-06-08 07:05:01 +00:00
|
|
|
lock := e.lockFor(order.InstrumentUID)
|
|
|
|
|
lock.Lock()
|
|
|
|
|
defer lock.Unlock()
|
2026-06-08 11:55:36 +00:00
|
|
|
if e.mode != domain.ModePaper && !e.mode.AllowsBrokerOrders() {
|
|
|
|
|
return order, ErrBrokerOrdersDisabled
|
|
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
if e.store != nil {
|
|
|
|
|
existing, err := e.findExisting(ctx, order)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
if existing.ClientOrderID != "" {
|
|
|
|
|
return existing, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-07 21:51:20 +00:00
|
|
|
if e.mode == domain.ModePaper {
|
2026-06-08 09:03:37 +00:00
|
|
|
return e.placePaperLimit(ctx, order, freeOrderLimit)
|
2026-06-07 21:51:20 +00:00
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
if e.gateway == nil {
|
|
|
|
|
return domain.Order{}, errors.New("gateway is nil")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 09:03:37 +00:00
|
|
|
now := e.nowUTC()
|
2026-06-08 07:05:01 +00:00
|
|
|
draft := order
|
|
|
|
|
draft.Status = domain.OrderStatusSent
|
|
|
|
|
draft.CreatedAt = now
|
|
|
|
|
draft.UpdatedAt = now
|
|
|
|
|
if draft.RawStateJSON == "" {
|
|
|
|
|
draft.RawStateJSON = "{}"
|
|
|
|
|
}
|
|
|
|
|
if e.store != nil {
|
2026-06-08 09:03:37 +00:00
|
|
|
if err := e.store.RunInTx(ctx, func(ctx context.Context, repo repository.Repository) error {
|
|
|
|
|
if err := repo.UpsertOrder(ctx, draft); err != nil {
|
|
|
|
|
return fmt.Errorf("persist draft order: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return repo.ReserveFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1, freeOrderLimit)
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return domain.Order{}, err
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
posted, err := e.gateway.PostLimitOrder(ctx, e.accountID, order.InstrumentUID, order.Side, order.QuantityLots, order.LimitPrice, order.ClientOrderID)
|
|
|
|
|
if err != nil {
|
2026-06-08 07:05:01 +00:00
|
|
|
draft.Status = domain.OrderStatusFailed
|
2026-06-07 21:01:40 +00:00
|
|
|
if e.store != nil {
|
2026-06-08 07:05:01 +00:00
|
|
|
_ = e.store.UpsertOrder(ctx, draft)
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
posted.ClientOrderID = order.ClientOrderID
|
|
|
|
|
posted.AccountIDHash = order.AccountIDHash
|
|
|
|
|
posted.InstrumentUID = order.InstrumentUID
|
|
|
|
|
posted.Side = order.Side
|
|
|
|
|
posted.OrderType = order.OrderType
|
|
|
|
|
posted.LimitPrice = order.LimitPrice
|
|
|
|
|
posted.QuantityLots = order.QuantityLots
|
|
|
|
|
posted.AttemptNo = order.AttemptNo
|
|
|
|
|
posted.TradeDate = order.TradeDate
|
2026-06-08 07:05:01 +00:00
|
|
|
posted.CreatedAt = now
|
2026-06-07 21:01:40 +00:00
|
|
|
posted.UpdatedAt = posted.CreatedAt
|
|
|
|
|
if e.store != nil {
|
2026-06-08 09:03:37 +00:00
|
|
|
if err := e.store.UpsertOrder(ctx, posted); err != nil {
|
2026-06-07 21:01:40 +00:00
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return posted, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 09:03:37 +00:00
|
|
|
func (e *Engine) placePaperLimit(ctx context.Context, order domain.Order, freeOrderLimit int) (domain.Order, error) {
|
|
|
|
|
now := e.nowUTC()
|
2026-06-07 21:51:20 +00:00
|
|
|
order.BrokerOrderID = "paper-" + order.ClientOrderID
|
|
|
|
|
order.FilledLots = order.QuantityLots
|
|
|
|
|
order.AvgFillPrice = order.LimitPrice
|
|
|
|
|
order.Status = domain.OrderStatusFilled
|
|
|
|
|
order.RawStateJSON = `{"paper_fill":true}`
|
|
|
|
|
order.CreatedAt = now
|
|
|
|
|
order.UpdatedAt = now
|
|
|
|
|
if e.store != nil {
|
|
|
|
|
if err := e.store.RunInTx(ctx, func(ctx context.Context, repo repository.Repository) error {
|
|
|
|
|
if err := repo.UpsertOrder(ctx, order); err != nil {
|
|
|
|
|
return fmt.Errorf("persist paper order: %w", err)
|
|
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
return repo.ReserveFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1, freeOrderLimit)
|
2026-06-07 21:51:20 +00:00
|
|
|
}); err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return order, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 21:01:40 +00:00
|
|
|
func (e *Engine) findExisting(ctx context.Context, order domain.Order) (domain.Order, error) {
|
|
|
|
|
orders, err := e.store.ListOrders(ctx, order.AccountIDHash, order.TradeDate, order.TradeDate)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
for _, existing := range orders {
|
2026-06-08 07:05:01 +00:00
|
|
|
if existing.ClientOrderID == order.ClientOrderID {
|
2026-06-07 21:01:40 +00:00
|
|
|
return existing, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return domain.Order{}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Engine) Refresh(ctx context.Context, order domain.Order) (domain.Order, error) {
|
|
|
|
|
if e.gateway == nil {
|
|
|
|
|
return domain.Order{}, errors.New("gateway is nil")
|
|
|
|
|
}
|
|
|
|
|
lock := e.lockFor(order.InstrumentUID)
|
|
|
|
|
lock.Lock()
|
|
|
|
|
defer lock.Unlock()
|
|
|
|
|
state, err := e.gateway.GetOrderState(ctx, e.accountID, order.BrokerOrderID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
state.ClientOrderID = order.ClientOrderID
|
|
|
|
|
state.AccountIDHash = order.AccountIDHash
|
|
|
|
|
state.InstrumentUID = order.InstrumentUID
|
|
|
|
|
state.TradeDate = order.TradeDate
|
|
|
|
|
state.Side = order.Side
|
|
|
|
|
state.OrderType = order.OrderType
|
|
|
|
|
state.LimitPrice = order.LimitPrice
|
|
|
|
|
state.QuantityLots = order.QuantityLots
|
|
|
|
|
state.AttemptNo = order.AttemptNo
|
|
|
|
|
if e.store != nil {
|
|
|
|
|
if err := e.store.UpsertOrder(ctx, state); err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return state, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Engine) Cancel(ctx context.Context, order domain.Order) error {
|
|
|
|
|
if e.gateway == nil {
|
|
|
|
|
return errors.New("gateway is nil")
|
|
|
|
|
}
|
|
|
|
|
lock := e.lockFor(order.InstrumentUID)
|
|
|
|
|
lock.Lock()
|
|
|
|
|
defer lock.Unlock()
|
|
|
|
|
if err := e.gateway.CancelOrder(ctx, e.accountID, order.BrokerOrderID); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if e.store != nil {
|
2026-06-08 07:36:52 +00:00
|
|
|
return e.store.RunInTx(ctx, func(ctx context.Context, repo repository.Repository) error {
|
|
|
|
|
if err := repo.UpdateOrderStatus(ctx, order.ClientOrderID, domain.OrderStatusCancelled, order.FilledLots, order.RawStateJSON); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if e.cancelCountsAsFreeOrder() {
|
|
|
|
|
return repo.IncrementFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Engine) MonitorUntil(ctx context.Context, order domain.Order, cfg MonitorConfig) (domain.Order, error) {
|
|
|
|
|
if cfg.PollInterval <= 0 {
|
|
|
|
|
cfg.PollInterval = 500 * time.Millisecond
|
|
|
|
|
}
|
|
|
|
|
if cfg.MaxAttempts <= 0 {
|
|
|
|
|
cfg.MaxAttempts = 1
|
|
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
lastPost := e.nowUTC()
|
2026-06-07 21:01:40 +00:00
|
|
|
current := order
|
|
|
|
|
aggregate := order
|
|
|
|
|
seen := map[string]domain.Order{order.ClientOrderID: order}
|
|
|
|
|
for {
|
|
|
|
|
previous := seen[current.ClientOrderID]
|
|
|
|
|
refreshed, err := e.Refresh(ctx, current)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return aggregate, err
|
|
|
|
|
}
|
|
|
|
|
aggregate = mergeAggregateFill(aggregate, previous, refreshed)
|
|
|
|
|
seen[current.ClientOrderID] = refreshed
|
|
|
|
|
current = mergeOrderState(current, refreshed)
|
|
|
|
|
aggregate.Status = current.Status
|
|
|
|
|
aggregate.UpdatedAt = current.UpdatedAt
|
|
|
|
|
aggregate.RawStateJSON = current.RawStateJSON
|
|
|
|
|
if aggregate.FilledLots >= aggregate.QuantityLots {
|
|
|
|
|
aggregate.Status = domain.OrderStatusFilled
|
|
|
|
|
return aggregate, nil
|
|
|
|
|
}
|
|
|
|
|
if isTerminal(current.Status) {
|
|
|
|
|
return aggregate, nil
|
|
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
2026-06-07 21:01:40 +00:00
|
|
|
if err := e.Cancel(ctx, current); err != nil {
|
|
|
|
|
return aggregate, err
|
|
|
|
|
}
|
|
|
|
|
aggregate.Status = domain.OrderStatusExpired
|
|
|
|
|
if e.store != nil {
|
|
|
|
|
if err := e.store.UpdateOrderStatus(ctx, current.ClientOrderID, aggregate.Status, current.FilledLots, current.RawStateJSON); err != nil {
|
|
|
|
|
return aggregate, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return aggregate, nil
|
|
|
|
|
}
|
|
|
|
|
shouldRepost := cfg.RepostAfter > 0 &&
|
2026-06-08 09:03:37 +00:00
|
|
|
e.nowUTC().Sub(lastPost) >= cfg.RepostAfter &&
|
2026-06-07 21:01:40 +00:00
|
|
|
current.AttemptNo < cfg.MaxAttempts &&
|
|
|
|
|
aggregate.FilledLots < aggregate.QuantityLots &&
|
|
|
|
|
cfg.Quote != nil
|
|
|
|
|
if shouldRepost {
|
2026-06-08 11:11:50 +00:00
|
|
|
result, err := e.repost(ctx, current, cfg, aggregate.QuantityLots-aggregate.FilledLots)
|
|
|
|
|
if result.Cancelled.ClientOrderID != "" {
|
|
|
|
|
previous := seen[result.Cancelled.ClientOrderID]
|
|
|
|
|
aggregate = mergeAggregateFill(aggregate, previous, result.Cancelled)
|
|
|
|
|
seen[result.Cancelled.ClientOrderID] = result.Cancelled
|
|
|
|
|
if aggregate.FilledLots >= aggregate.QuantityLots {
|
|
|
|
|
aggregate.Status = domain.OrderStatusFilled
|
|
|
|
|
return aggregate, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return aggregate, err
|
|
|
|
|
}
|
2026-06-08 11:11:50 +00:00
|
|
|
if result.Changed {
|
|
|
|
|
current = result.Current
|
2026-06-08 07:05:01 +00:00
|
|
|
seen[current.ClientOrderID] = current
|
2026-06-08 11:11:50 +00:00
|
|
|
aggregate.Status = current.Status
|
|
|
|
|
aggregate.UpdatedAt = current.UpdatedAt
|
|
|
|
|
aggregate.RawStateJSON = current.RawStateJSON
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
lastPost = e.nowUTC()
|
2026-06-07 21:01:40 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
if !e.sleep(ctx, cfg.PollInterval) {
|
2026-06-07 21:01:40 +00:00
|
|
|
return aggregate, ctx.Err()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 07:05:01 +00:00
|
|
|
func (e *Engine) MonitorOnce(ctx context.Context, order domain.Order, cfg MonitorConfig) (domain.Order, error) {
|
|
|
|
|
if cfg.PollInterval <= 0 {
|
|
|
|
|
cfg.PollInterval = 500 * time.Millisecond
|
|
|
|
|
}
|
|
|
|
|
if cfg.MaxAttempts <= 0 {
|
|
|
|
|
cfg.MaxAttempts = 1
|
|
|
|
|
}
|
|
|
|
|
previous := order
|
|
|
|
|
refreshed, err := e.Refresh(ctx, order)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return order, err
|
|
|
|
|
}
|
|
|
|
|
aggregate := mergeAggregateFill(order, previous, refreshed)
|
|
|
|
|
current := mergeOrderState(order, refreshed)
|
|
|
|
|
aggregate.Status = current.Status
|
|
|
|
|
aggregate.UpdatedAt = current.UpdatedAt
|
|
|
|
|
aggregate.RawStateJSON = current.RawStateJSON
|
|
|
|
|
if aggregate.FilledLots >= aggregate.QuantityLots {
|
|
|
|
|
aggregate.Status = domain.OrderStatusFilled
|
|
|
|
|
return aggregate, nil
|
|
|
|
|
}
|
|
|
|
|
if isTerminal(current.Status) {
|
|
|
|
|
return aggregate, nil
|
|
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
2026-06-08 07:05:01 +00:00
|
|
|
if err := e.Cancel(ctx, current); err != nil {
|
|
|
|
|
return aggregate, err
|
|
|
|
|
}
|
|
|
|
|
aggregate.Status = domain.OrderStatusExpired
|
|
|
|
|
if e.store != nil {
|
|
|
|
|
if err := e.store.UpdateOrderStatus(ctx, current.ClientOrderID, aggregate.Status, current.FilledLots, current.RawStateJSON); err != nil {
|
|
|
|
|
return aggregate, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return aggregate, nil
|
|
|
|
|
}
|
|
|
|
|
shouldRepost := cfg.RepostAfter > 0 &&
|
2026-06-08 09:03:37 +00:00
|
|
|
e.repostDue(current, cfg.RepostAfter) &&
|
2026-06-08 07:05:01 +00:00
|
|
|
current.AttemptNo < cfg.MaxAttempts &&
|
|
|
|
|
aggregate.FilledLots < aggregate.QuantityLots &&
|
|
|
|
|
cfg.Quote != nil
|
|
|
|
|
if shouldRepost {
|
2026-06-08 11:11:50 +00:00
|
|
|
result, err := e.repost(ctx, current, cfg, aggregate.QuantityLots-aggregate.FilledLots)
|
|
|
|
|
if result.Cancelled.ClientOrderID != "" {
|
|
|
|
|
aggregate = mergeAggregateFill(aggregate, current, result.Cancelled)
|
|
|
|
|
if aggregate.FilledLots >= aggregate.QuantityLots {
|
|
|
|
|
aggregate.Status = domain.OrderStatusFilled
|
|
|
|
|
return aggregate, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-08 07:05:01 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return aggregate, err
|
|
|
|
|
}
|
2026-06-08 11:11:50 +00:00
|
|
|
if result.Changed {
|
|
|
|
|
aggregate.BrokerOrderID = result.Current.BrokerOrderID
|
|
|
|
|
aggregate.ClientOrderID = result.Current.ClientOrderID
|
|
|
|
|
aggregate.Status = result.Current.Status
|
|
|
|
|
aggregate.RawStateJSON = result.Current.RawStateJSON
|
|
|
|
|
aggregate.UpdatedAt = result.Current.UpdatedAt
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return aggregate, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 11:11:50 +00:00
|
|
|
func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConfig, remaining int64) (repostResult, error) {
|
2026-06-07 21:51:20 +00:00
|
|
|
if err := e.ensureRepostBudget(ctx, order, cfg.Instrument); err != nil {
|
2026-06-08 11:11:50 +00:00
|
|
|
return repostResult{}, err
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
2026-06-08 11:11:50 +00:00
|
|
|
return repostResult{Current: order}, nil
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
|
|
|
|
book, err := cfg.Quote(ctx, order.InstrumentUID)
|
|
|
|
|
if err != nil {
|
2026-06-08 11:11:50 +00:00
|
|
|
return repostResult{}, err
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
|
|
|
|
if cfg.RepostCheck != nil {
|
|
|
|
|
if err := cfg.RepostCheck(ctx, order, cfg.Instrument, book); err != nil {
|
2026-06-08 11:11:50 +00:00
|
|
|
return repostResult{Current: order}, nil
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
2026-06-07 21:51:20 +00:00
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
if err := e.Cancel(ctx, order); err != nil {
|
2026-06-08 11:11:50 +00:00
|
|
|
return repostResult{}, err
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
|
|
|
|
cancelled, err := e.waitTerminal(ctx, order, cfg)
|
|
|
|
|
if err != nil {
|
2026-06-08 11:11:50 +00:00
|
|
|
return repostResult{}, err
|
|
|
|
|
}
|
|
|
|
|
result := repostResult{Current: cancelled, Changed: true, Cancelled: cancelled}
|
|
|
|
|
additionalFilled := cancelled.FilledLots - order.FilledLots
|
|
|
|
|
if additionalFilled > 0 {
|
|
|
|
|
remaining -= additionalFilled
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
if remaining <= 0 {
|
2026-06-08 11:11:50 +00:00
|
|
|
return result, nil
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
2026-06-08 11:11:50 +00:00
|
|
|
return result, nil
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
|
|
|
|
book, err = cfg.Quote(ctx, order.InstrumentUID)
|
2026-06-07 21:01:40 +00:00
|
|
|
if err != nil {
|
2026-06-08 11:11:50 +00:00
|
|
|
return result, err
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
|
|
|
|
if cfg.RepostCheck != nil {
|
2026-06-08 09:03:37 +00:00
|
|
|
if err := cfg.RepostCheck(ctx, cancelled, cfg.Instrument, book); err != nil {
|
2026-06-08 11:11:50 +00:00
|
|
|
return result, nil
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
attempt := order.AttemptNo + 1
|
2026-06-08 11:11:50 +00:00
|
|
|
var next domain.Order
|
2026-06-07 21:01:40 +00:00
|
|
|
switch order.Side {
|
|
|
|
|
case domain.SideBuy:
|
2026-06-08 11:11:50 +00:00
|
|
|
next, err = e.PlaceEntry(ctx, order.AccountIDHash, cfg.Instrument, order.TradeDate, remaining, book, cfg.ImproveTicks, attempt)
|
2026-06-07 21:01:40 +00:00
|
|
|
case domain.SideSell:
|
2026-06-08 11:11:50 +00:00
|
|
|
next, err = e.PlaceExit(ctx, order.AccountIDHash, cfg.Instrument, order.TradeDate, remaining, book, cfg.ImproveTicks, attempt)
|
2026-06-07 21:01:40 +00:00
|
|
|
default:
|
2026-06-08 11:11:50 +00:00
|
|
|
return result, fmt.Errorf("unsupported side %s", order.Side)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return result, err
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
2026-06-08 11:11:50 +00:00
|
|
|
result.Current = next
|
|
|
|
|
return result, nil
|
2026-06-08 07:05:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Engine) waitTerminal(ctx context.Context, order domain.Order, cfg MonitorConfig) (domain.Order, error) {
|
|
|
|
|
current := order
|
|
|
|
|
for {
|
|
|
|
|
refreshed, err := e.Refresh(ctx, current)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.Order{}, err
|
|
|
|
|
}
|
|
|
|
|
current = mergeOrderState(current, refreshed)
|
|
|
|
|
if isTerminal(current.Status) {
|
|
|
|
|
return current, nil
|
|
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
2026-06-08 07:05:01 +00:00
|
|
|
return current, nil
|
|
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
if !e.sleep(ctx, cfg.PollInterval) {
|
2026-06-08 07:05:01 +00:00
|
|
|
return domain.Order{}, ctx.Err()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 09:03:37 +00:00
|
|
|
func (e *Engine) repostDue(order domain.Order, after time.Duration) bool {
|
2026-06-08 07:05:01 +00:00
|
|
|
if after <= 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
basis := order.CreatedAt
|
|
|
|
|
if basis.IsZero() {
|
|
|
|
|
basis = order.UpdatedAt
|
|
|
|
|
}
|
|
|
|
|
if basis.IsZero() {
|
|
|
|
|
return true
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
2026-06-08 09:03:37 +00:00
|
|
|
return e.nowUTC().Sub(basis) >= after
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-07 21:51:20 +00:00
|
|
|
func (e *Engine) ensureRepostBudget(ctx context.Context, order domain.Order, instrument domain.Instrument) error {
|
2026-06-08 09:41:20 +00:00
|
|
|
if e.store == nil || instrument.FreeOrderLimitPerDay < 0 {
|
2026-06-07 21:51:20 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-06-08 09:41:20 +00:00
|
|
|
if instrument.FreeOrderLimitPerDay == 0 {
|
|
|
|
|
return risk.ErrFreeOrderPolicyUnspecified
|
|
|
|
|
}
|
2026-06-07 21:51:20 +00:00
|
|
|
sent, err := e.store.GetFreeOrdersSent(ctx, order.TradeDate, instrument.InstrumentUID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-06-08 07:36:52 +00:00
|
|
|
needed := 1
|
|
|
|
|
if e.cancelCountsAsFreeOrder() {
|
|
|
|
|
needed = 2
|
|
|
|
|
}
|
|
|
|
|
remaining := instrument.FreeOrderLimitPerDay - sent
|
|
|
|
|
if remaining < needed {
|
|
|
|
|
return fmt.Errorf("%w: %s remaining=%d needed=%d", risk.ErrFreeOrderBudget, instrument.InstrumentUID, remaining, needed)
|
2026-06-07 21:51:20 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 07:36:52 +00:00
|
|
|
func (e *Engine) cancelCountsAsFreeOrder() bool {
|
|
|
|
|
return e.freeOrderCountPolicy == FreeOrderPolicyCancelCounts
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 21:01:40 +00:00
|
|
|
func (e *Engine) checkQuoteFresh(book domain.OrderBook) error {
|
|
|
|
|
if e.maxQuoteAge <= 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-06-08 11:55:36 +00:00
|
|
|
quoteTs := quoteTimestamp(book)
|
|
|
|
|
if quoteTs.IsZero() {
|
|
|
|
|
return fmt.Errorf("quote timestamp is missing")
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
2026-06-08 11:55:36 +00:00
|
|
|
age := e.nowUTC().Sub(quoteTs)
|
2026-06-07 21:01:40 +00:00
|
|
|
if age > e.maxQuoteAge {
|
|
|
|
|
return fmt.Errorf("quote age %s exceeds %s", age, e.maxQuoteAge)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 11:55:36 +00:00
|
|
|
func quoteTimestamp(book domain.OrderBook) time.Time {
|
|
|
|
|
if !book.Time.IsZero() {
|
|
|
|
|
return book.Time.UTC()
|
|
|
|
|
}
|
|
|
|
|
return book.ReceivedAt.UTC()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 21:01:40 +00:00
|
|
|
func (e *Engine) lockFor(instrumentUID string) *sync.Mutex {
|
|
|
|
|
value, _ := e.mu.LoadOrStore(instrumentUID, &sync.Mutex{})
|
|
|
|
|
lock, ok := value.(*sync.Mutex)
|
|
|
|
|
if !ok {
|
2026-06-08 09:03:37 +00:00
|
|
|
lock = &sync.Mutex{}
|
|
|
|
|
e.mu.Store(instrumentUID, lock)
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
return lock
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 09:03:37 +00:00
|
|
|
func (e *Engine) nowUTC() time.Time {
|
|
|
|
|
if e.clock == nil {
|
|
|
|
|
return time.Now().UTC()
|
|
|
|
|
}
|
|
|
|
|
return e.clock.Now().UTC()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Engine) sleep(ctx context.Context, d time.Duration) bool {
|
|
|
|
|
if d <= 0 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if e.clock == nil {
|
|
|
|
|
return timeutil.RealClock{}.Sleep(ctx.Done(), d)
|
|
|
|
|
}
|
|
|
|
|
return e.clock.Sleep(ctx.Done(), d)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 21:01:40 +00:00
|
|
|
func bestBidAsk(book domain.OrderBook) (decimal.Decimal, decimal.Decimal, error) {
|
|
|
|
|
bid, ok := book.BestBid()
|
|
|
|
|
if !ok {
|
|
|
|
|
return decimal.Zero, decimal.Zero, ErrEmptyOrderBook
|
|
|
|
|
}
|
|
|
|
|
ask, ok := book.BestAsk()
|
|
|
|
|
if !ok {
|
|
|
|
|
return decimal.Zero, decimal.Zero, ErrEmptyOrderBook
|
|
|
|
|
}
|
|
|
|
|
return bid, ask, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isTerminal(status domain.OrderStatus) bool {
|
|
|
|
|
switch status {
|
|
|
|
|
case domain.OrderStatusFilled, domain.OrderStatusCancelled, domain.OrderStatusRejected, domain.OrderStatusExpired, domain.OrderStatusFailed:
|
|
|
|
|
return true
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func mergeOrderState(base, state domain.Order) domain.Order {
|
|
|
|
|
base.BrokerOrderID = state.BrokerOrderID
|
|
|
|
|
base.FilledLots = state.FilledLots
|
|
|
|
|
base.AvgFillPrice = state.AvgFillPrice
|
|
|
|
|
base.Status = state.Status
|
|
|
|
|
base.Commission = state.Commission
|
|
|
|
|
base.RawStateJSON = state.RawStateJSON
|
|
|
|
|
base.UpdatedAt = state.UpdatedAt
|
|
|
|
|
return base
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func mergeAggregateFill(aggregate, previous, current domain.Order) domain.Order {
|
|
|
|
|
deltaLots := current.FilledLots - previous.FilledLots
|
|
|
|
|
if deltaLots > 0 {
|
|
|
|
|
deltaAvg := fillDeltaAvg(previous, current, deltaLots)
|
|
|
|
|
previousValue := aggregate.AvgFillPrice.Mul(decimal.NewFromInt(aggregate.FilledLots))
|
|
|
|
|
deltaValue := deltaAvg.Mul(decimal.NewFromInt(deltaLots))
|
|
|
|
|
aggregate.FilledLots += deltaLots
|
|
|
|
|
aggregate.AvgFillPrice = previousValue.Add(deltaValue).Div(decimal.NewFromInt(aggregate.FilledLots))
|
|
|
|
|
}
|
|
|
|
|
deltaCommission := current.Commission.Sub(previous.Commission)
|
|
|
|
|
if deltaCommission.IsPositive() {
|
|
|
|
|
aggregate.Commission = aggregate.Commission.Add(deltaCommission)
|
|
|
|
|
}
|
|
|
|
|
return aggregate
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fillDeltaAvg(previous, current domain.Order, deltaLots int64) decimal.Decimal {
|
|
|
|
|
if deltaLots <= 0 {
|
|
|
|
|
return decimal.Zero
|
|
|
|
|
}
|
|
|
|
|
if previous.FilledLots <= 0 {
|
|
|
|
|
if current.AvgFillPrice.IsPositive() {
|
|
|
|
|
return current.AvgFillPrice
|
|
|
|
|
}
|
|
|
|
|
return current.LimitPrice
|
|
|
|
|
}
|
|
|
|
|
currentValue := current.AvgFillPrice.Mul(decimal.NewFromInt(current.FilledLots))
|
|
|
|
|
previousValue := previous.AvgFillPrice.Mul(decimal.NewFromInt(previous.FilledLots))
|
|
|
|
|
if currentValue.GreaterThan(previousValue) {
|
|
|
|
|
return currentValue.Sub(previousValue).Div(decimal.NewFromInt(deltaLots))
|
|
|
|
|
}
|
|
|
|
|
if current.AvgFillPrice.IsPositive() {
|
|
|
|
|
return current.AvgFillPrice
|
|
|
|
|
}
|
|
|
|
|
return current.LimitPrice
|
|
|
|
|
}
|