seventh version
This commit is contained in:
@@ -51,6 +51,12 @@ type MonitorConfig struct {
|
||||
RepostCheck func(ctx context.Context, order domain.Order, instrument domain.Instrument, book domain.OrderBook) error
|
||||
}
|
||||
|
||||
type repostResult struct {
|
||||
Current domain.Order
|
||||
Changed bool
|
||||
Cancelled domain.Order
|
||||
}
|
||||
|
||||
func NewEngine(mode domain.Mode, accountID string, gateway Gateway, store repository.Repository) Engine {
|
||||
return Engine{
|
||||
mode: mode,
|
||||
@@ -346,13 +352,25 @@ func (e *Engine) MonitorUntil(ctx context.Context, order domain.Order, cfg Monit
|
||||
aggregate.FilledLots < aggregate.QuantityLots &&
|
||||
cfg.Quote != nil
|
||||
if shouldRepost {
|
||||
next, reposted, err := e.repost(ctx, current, cfg, aggregate.QuantityLots-aggregate.FilledLots)
|
||||
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
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return aggregate, err
|
||||
}
|
||||
if reposted {
|
||||
current = next
|
||||
if result.Changed {
|
||||
current = result.Current
|
||||
seen[current.ClientOrderID] = current
|
||||
aggregate.Status = current.Status
|
||||
aggregate.UpdatedAt = current.UpdatedAt
|
||||
aggregate.RawStateJSON = current.RawStateJSON
|
||||
}
|
||||
lastPost = e.nowUTC()
|
||||
continue
|
||||
@@ -405,71 +423,86 @@ func (e *Engine) MonitorOnce(ctx context.Context, order domain.Order, cfg Monito
|
||||
aggregate.FilledLots < aggregate.QuantityLots &&
|
||||
cfg.Quote != nil
|
||||
if shouldRepost {
|
||||
next, reposted, err := e.repost(ctx, current, cfg, aggregate.QuantityLots-aggregate.FilledLots)
|
||||
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
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return aggregate, err
|
||||
}
|
||||
if reposted {
|
||||
aggregate.BrokerOrderID = next.BrokerOrderID
|
||||
aggregate.ClientOrderID = next.ClientOrderID
|
||||
aggregate.Status = next.Status
|
||||
aggregate.RawStateJSON = next.RawStateJSON
|
||||
aggregate.UpdatedAt = next.UpdatedAt
|
||||
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
|
||||
}
|
||||
}
|
||||
return aggregate, nil
|
||||
}
|
||||
|
||||
func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConfig, remaining int64) (domain.Order, bool, error) {
|
||||
func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConfig, remaining int64) (repostResult, error) {
|
||||
if err := e.ensureRepostBudget(ctx, order, cfg.Instrument); err != nil {
|
||||
return domain.Order{}, false, err
|
||||
return repostResult{}, err
|
||||
}
|
||||
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
||||
return order, false, nil
|
||||
return repostResult{Current: order}, nil
|
||||
}
|
||||
book, err := cfg.Quote(ctx, order.InstrumentUID)
|
||||
if err != nil {
|
||||
return domain.Order{}, false, err
|
||||
return repostResult{}, err
|
||||
}
|
||||
if cfg.RepostCheck != nil {
|
||||
if err := cfg.RepostCheck(ctx, order, cfg.Instrument, book); err != nil {
|
||||
return order, false, nil
|
||||
return repostResult{Current: order}, nil
|
||||
}
|
||||
}
|
||||
if err := e.Cancel(ctx, order); err != nil {
|
||||
return domain.Order{}, false, err
|
||||
return repostResult{}, err
|
||||
}
|
||||
cancelled, err := e.waitTerminal(ctx, order, cfg)
|
||||
if err != nil {
|
||||
return domain.Order{}, false, err
|
||||
return repostResult{}, err
|
||||
}
|
||||
result := repostResult{Current: cancelled, Changed: true, Cancelled: cancelled}
|
||||
additionalFilled := cancelled.FilledLots - order.FilledLots
|
||||
if additionalFilled > 0 {
|
||||
remaining -= additionalFilled
|
||||
}
|
||||
if remaining <= 0 {
|
||||
cancelled.Status = domain.OrderStatusFilled
|
||||
return cancelled, true, nil
|
||||
return result, nil
|
||||
}
|
||||
if !cfg.Deadline.IsZero() && !e.nowUTC().Before(cfg.Deadline) {
|
||||
return cancelled, true, nil
|
||||
return result, nil
|
||||
}
|
||||
book, err = cfg.Quote(ctx, order.InstrumentUID)
|
||||
if err != nil {
|
||||
return domain.Order{}, false, err
|
||||
return result, err
|
||||
}
|
||||
if cfg.RepostCheck != nil {
|
||||
if err := cfg.RepostCheck(ctx, cancelled, cfg.Instrument, book); err != nil {
|
||||
return cancelled, true, nil
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
attempt := order.AttemptNo + 1
|
||||
var next domain.Order
|
||||
switch order.Side {
|
||||
case domain.SideBuy:
|
||||
next, err := e.PlaceEntry(ctx, order.AccountIDHash, cfg.Instrument, order.TradeDate, remaining, book, cfg.ImproveTicks, attempt)
|
||||
return next, true, err
|
||||
next, err = e.PlaceEntry(ctx, order.AccountIDHash, cfg.Instrument, order.TradeDate, remaining, book, cfg.ImproveTicks, attempt)
|
||||
case domain.SideSell:
|
||||
next, err := e.PlaceExit(ctx, order.AccountIDHash, cfg.Instrument, order.TradeDate, remaining, book, cfg.ImproveTicks, attempt)
|
||||
return next, true, err
|
||||
next, err = e.PlaceExit(ctx, order.AccountIDHash, cfg.Instrument, order.TradeDate, remaining, book, cfg.ImproveTicks, attempt)
|
||||
default:
|
||||
return domain.Order{}, false, fmt.Errorf("unsupported side %s", order.Side)
|
||||
return result, fmt.Errorf("unsupported side %s", order.Side)
|
||||
}
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Current = next
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *Engine) waitTerminal(ctx context.Context, order domain.Order, cfg MonitorConfig) (domain.Order, error) {
|
||||
|
||||
@@ -342,3 +342,164 @@ func TestMonitorOnceDoesNotRepostWhenCheckRejects(t *testing.T) {
|
||||
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 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 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user