From d8b0560dad9a75753f2764fefa2063f719d7ecbd Mon Sep 17 00:00:00 2001 From: c9s Date: Fri, 24 Jan 2025 19:20:33 +0800 Subject: [PATCH 01/12] xmaker: implement spread maker feature --- pkg/strategy/xmaker/delayedhedge.go | 23 ++++ pkg/strategy/xmaker/metrics.go | 22 ++++ pkg/strategy/xmaker/spreadmaker.go | 192 ++++++++++++++++++++++++++++ pkg/strategy/xmaker/strategy.go | 127 ++++++++++++++---- pkg/types/order.go | 11 ++ pkg/types/position.go | 17 +++ pkg/types/side.go | 1 + 7 files changed, 370 insertions(+), 23 deletions(-) create mode 100644 pkg/strategy/xmaker/delayedhedge.go create mode 100644 pkg/strategy/xmaker/spreadmaker.go diff --git a/pkg/strategy/xmaker/delayedhedge.go b/pkg/strategy/xmaker/delayedhedge.go new file mode 100644 index 0000000000..25f14e0127 --- /dev/null +++ b/pkg/strategy/xmaker/delayedhedge.go @@ -0,0 +1,23 @@ +package xmaker + +import ( + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/types" +) + +type DelayedHedge struct { + // EnableDelayHedge enables the delay hedge feature + Enabled bool `json:"enabled"` + + // MaxDelayDuration is the maximum delay duration to hedge the position + MaxDelayDuration types.Duration `json:"maxDelay"` + + // FixedDelayDuration is the fixed delay duration + FixedDelayDuration types.Duration `json:"fixedDelay"` + + // SignalThreshold is the signal threshold to trigger the delay hedge + SignalThreshold float64 `json:"signalThreshold"` + + // DynamicDelayScale is the dynamic delay scale + DynamicDelayScale *bbgo.SlideRule `json:"dynamicDelayScale,omitempty"` +} diff --git a/pkg/strategy/xmaker/metrics.go b/pkg/strategy/xmaker/metrics.go index 942f1933c0..3662fd43e0 100644 --- a/pkg/strategy/xmaker/metrics.go +++ b/pkg/strategy/xmaker/metrics.go @@ -78,6 +78,24 @@ var netProfitMarginHistogram = prometheus.NewHistogramVec( Buckets: prometheus.ExponentialBuckets(0.001, 2.0, 10), }, []string{"strategy_type", "strategy_id", "exchange", "symbol"}) +var spreadMakerCounterMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "xmaker_spread_maker_counter", + Help: "spread maker counter", + }, []string{"strategy_type", "strategy_id", "exchange", "symbol"}) + +var spreadMakerVolumeMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "xmaker_spread_maker_volume", + Help: "spread maker volume", + }, []string{"strategy_type", "strategy_id", "exchange", "symbol"}) + +var spreadMakerQuoteVolumeMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "xmaker_spread_maker_quote_volume", + Help: "spread maker quote volume", + }, []string{"strategy_type", "strategy_id", "exchange", "symbol"}) + func init() { prometheus.MustRegister( openOrderBidExposureInUsdMetrics, @@ -92,5 +110,9 @@ func init() { delayedHedgeCounterMetrics, delayedHedgeMaxDurationMetrics, netProfitMarginHistogram, + + spreadMakerCounterMetrics, + spreadMakerVolumeMetrics, + spreadMakerQuoteVolumeMetrics, ) } diff --git a/pkg/strategy/xmaker/spreadmaker.go b/pkg/strategy/xmaker/spreadmaker.go new file mode 100644 index 0000000000..4f2f23b5cd --- /dev/null +++ b/pkg/strategy/xmaker/spreadmaker.go @@ -0,0 +1,192 @@ +package xmaker + +import ( + "context" + "sync" + "time" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/core" + "github.com/c9s/bbgo/pkg/exchange/retry" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +type SpreadMaker struct { + Enabled bool `json:"enabled"` + + MinProfitRatio fixedpoint.Value `json:"minProfitRatio"` + MaxQuoteAmount fixedpoint.Value `json:"maxQuoteAmount"` + MaxOrderLifespan types.Duration `json:"maxOrderLifespan"` + MakerOnly bool `json:"makerOnly"` + + // order is the current spread maker order on the maker exchange + order *types.Order + + // orderStore stores the history maker orders + orderStore *core.OrderStore + + session *bbgo.ExchangeSession + + market types.Market + + orderQueryService types.ExchangeOrderQueryService + + symbol string + + mu sync.Mutex +} + +func (c *SpreadMaker) Defaults() error { + if c.MinProfitRatio.IsZero() { + c.MinProfitRatio = fixedpoint.NewFromFloat(0.01 * 0.01) + } + + if c.MaxQuoteAmount.IsZero() { + c.MaxQuoteAmount = fixedpoint.NewFromFloat(100) + } + + if c.MaxOrderLifespan == 0 { + c.MaxOrderLifespan = types.Duration(2 * time.Second) + } + + return nil +} + +func (c *SpreadMaker) updateOrder(ctx context.Context) (*types.Order, error) { + c.mu.Lock() + defer c.mu.Unlock() + + retOrder, err := c.orderQueryService.QueryOrder(ctx, c.order.AsQuery()) + if err != nil { + return nil, err + } + + c.order = retOrder + return retOrder, nil +} + +func (c *SpreadMaker) canSpreadMaking( + signal float64, position *types.Position, + makerMarket types.Market, + bestBidPrice, bestAskPrice fixedpoint.Value, // maker best bid price +) (*types.SubmitOrder, bool) { + side := position.Side() + if !isSignalSidePosition(signal, side) { + return nil, false + } + + base := position.GetBase() + cost := position.GetAverageCost() + profitPrice := getPositionProfitPrice(side, cost, c.MinProfitRatio) + + maxQuantity := c.MaxQuoteAmount.Div(cost) + orderQuantity := fixedpoint.Min(base, maxQuantity) + orderSide := side.Reverse() + + switch side { + case types.SideTypeBuy: + targetPrice := bestBidPrice.Add(makerMarket.TickSize) + targetPrice = fixedpoint.Max(profitPrice, targetPrice) + return c.newMakerOrder(makerMarket, orderSide, targetPrice, orderQuantity), true + + case types.SideTypeSell: + targetPrice := bestAskPrice.Sub(makerMarket.TickSize) + targetPrice = fixedpoint.Min(profitPrice, targetPrice) + return c.newMakerOrder(makerMarket, orderSide, targetPrice, orderQuantity), true + } + + return nil, false +} + +func (c *SpreadMaker) newMakerOrder( + market types.Market, + side types.SideType, + targetPrice, orderQuantity fixedpoint.Value, +) *types.SubmitOrder { + orderType := types.OrderTypeLimit + if c.MakerOnly { + orderType = types.OrderTypeLimitMaker + } + + return &types.SubmitOrder{ + // ClientOrderID: "", + Symbol: c.symbol, + Side: side, + Type: orderType, + Price: targetPrice, + Quantity: orderQuantity, + Market: market, + TimeInForce: types.TimeInForceGTC, + } +} + +func (c *SpreadMaker) getOrder() (o types.Order, ok bool) { + c.mu.Lock() + if c.order != nil { + o = *c.order + ok = true + } + c.mu.Unlock() + return o, ok +} + +func (c *SpreadMaker) cancelOrder(ctx context.Context) error { + if order, ok := c.getOrder(); ok { + return retry.CancelOrdersUntilSuccessful(ctx, c.session.Exchange, order) + } + + return nil +} + +// cancelAndQueryOrder cancels the current order and queries the order status until the order is canceled +func (c *SpreadMaker) cancelAndQueryOrder(ctx context.Context) (*types.Order, error) { + if c.order == nil { + return nil, nil + } + + if err := c.cancelOrder(ctx); err != nil { + return nil, err + } + + finalOrder, err := retry.QueryOrderUntilCanceled(ctx, c.orderQueryService, c.order.Symbol, c.order.OrderID) + if err != nil { + return nil, err + } + + return finalOrder, nil +} + +func (c *SpreadMaker) shouldKeepOrder(o types.Order, now time.Time) bool { + creationTime := o.CreationTime.Time() + if creationTime.IsZero() { + return false + } + + if creationTime.Add(c.MaxOrderLifespan.Duration()).Before(now) { + return true + } + + return false +} + +func (c *SpreadMaker) placeOrder(ctx context.Context, submitOrder *types.SubmitOrder) (*types.Order, error) { + createdOrder, err := c.session.Exchange.SubmitOrder(ctx, *submitOrder) + if err != nil { + return nil, err + } + + c.mu.Lock() + c.order = createdOrder + c.mu.Unlock() + return createdOrder, nil +} + +func (c *SpreadMaker) Bind(ctx context.Context, session *bbgo.ExchangeSession, symbol string) error { + c.symbol = symbol + c.orderStore = core.NewOrderStore(symbol) + c.session = session + c.market, _ = c.session.Market(symbol) + c.orderQueryService = c.session.Exchange.(types.ExchangeOrderQueryService) + return nil +} diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index 6f4744ff53..9635d6e699 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -121,23 +121,6 @@ type SignalMargin struct { Threshold float64 `json:"threshold,omitempty"` } -type DelayedHedge struct { - // EnableDelayHedge enables the delay hedge feature - Enabled bool `json:"enabled"` - - // MaxDelayDuration is the maximum delay duration to hedge the position - MaxDelayDuration types.Duration `json:"maxDelay"` - - // FixedDelayDuration is the fixed delay duration - FixedDelayDuration types.Duration `json:"fixedDelay"` - - // SignalThreshold is the signal threshold to trigger the delay hedge - SignalThreshold float64 `json:"signalThreshold"` - - // DynamicDelayScale is the dynamic delay scale - DynamicDelayScale *bbgo.SlideRule `json:"dynamicDelayScale,omitempty"` -} - type Strategy struct { Environment *bbgo.Environment @@ -181,6 +164,8 @@ type Strategy struct { DelayedHedge *DelayedHedge `json:"delayedHedge,omitempty"` + SpreadMaker *SpreadMaker `json:"spreadMaker,omitempty"` + EnableBollBandMargin bool `json:"enableBollBandMargin"` BollBandInterval types.Interval `json:"bollBandInterval"` BollBandMargin fixedpoint.Value `json:"bollBandMargin"` @@ -1425,7 +1410,6 @@ func (s *Strategy) canDelayHedge(hedgeSide types.SideType, pos fixedpoint.Value) } signal := s.lastAggregatedSignal.Get() - signalAbs := math.Abs(signal) if signalAbs < s.DelayedHedge.SignalThreshold { return false @@ -1464,22 +1448,86 @@ func (s *Strategy) canDelayHedge(hedgeSide types.SideType, pos fixedpoint.Value) } func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { - side := types.SideTypeBuy if pos.IsZero() { return } - quantity := pos.Abs() - + side := types.SideTypeBuy if pos.Sign() < 0 { side = types.SideTypeSell } + now := time.Now() + signal := s.lastAggregatedSignal.Get() + + if s.SpreadMaker != nil && s.SpreadMaker.Enabled && s.makerBook != nil { + if makerBid, makerAsk, hasPrice := s.makerBook.BestBidAndAsk(); hasPrice { + if makerOrderForm, ok := s.SpreadMaker.canSpreadMaking(signal, s.Position, s.makerMarket, makerBid.Price, makerAsk.Price); ok { + + spreadMakerCounterMetrics.With(s.metricsLabels).Inc() + + s.logger.Infof("spread maker order form: %+v", makerOrderForm) + + // if we have the existing order, cancel it and return the covered position + // keptOrder means we kept the current order and we don't need to place a new order + keptOrder := false + curOrder, hasOrder := s.SpreadMaker.getOrder() + if hasOrder { + keptOrder = s.SpreadMaker.shouldKeepOrder(curOrder, now) + if !keptOrder { + s.logger.Infof("canceling current spread maker order...") + + finalOrder, err := s.SpreadMaker.cancelAndQueryOrder(ctx) + if err != nil { + s.logger.WithError(err).Errorf("spread maker: cancel order error") + } + + if finalOrder != nil { + spreadMakerVolumeMetrics.With(s.metricsLabels).Add(finalOrder.ExecutedQuantity.Float64()) + spreadMakerQuoteVolumeMetrics.With(s.metricsLabels).Add(finalOrder.ExecutedQuantity.Mul(finalOrder.Price).Float64()) + + remainingQuantity := finalOrder.GetRemainingQuantity() + + s.logger.Infof("returning remaining quantity %f to the covered position", remainingQuantity.Float64()) + switch finalOrder.Side { + case types.SideTypeSell: + s.coveredPosition.Sub(remainingQuantity) + case types.SideTypeBuy: + s.coveredPosition.Add(remainingQuantity) + + } + } + } + } + + if !keptOrder { + s.logger.Infof("placing new spread maker order: %+v...", makerOrderForm) + + retOrder, err := s.SpreadMaker.placeOrder(ctx, makerOrderForm) + if err != nil { + s.logger.WithError(err).Errorf("unable to place spread maker order") + } else { + // add covered position from the created order + switch side { + case types.SideTypeSell: + s.coveredPosition.Add(retOrder.Quantity) + pos = pos.Add(retOrder.Quantity) + case types.SideTypeBuy: + s.coveredPosition.Sub(retOrder.Quantity) + pos = pos.Sub(retOrder.Quantity) + } + } + } + } + } + } + if s.canDelayHedge(side, pos) { return } lastPrice := s.lastPrice.Get() + quantity := pos.Abs() bestBid, bestAsk, ok := s.sourceBook.BestBidAndAsk() if ok { @@ -1560,9 +1608,10 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { log.Infof("submitted hedge orders: %+v", createdOrders) // if it's selling, then we should add a positive position - if side == types.SideTypeSell { + switch side { + case types.SideTypeSell: s.coveredPosition.Add(quantity) - } else { + case types.SideTypeBuy: s.coveredPosition.Add(quantity.Neg()) } @@ -2034,6 +2083,12 @@ func (s *Strategy) CrossRun( s.ProfitStats.ProfitStats = profitStats } + if s.SpreadMaker != nil && s.SpreadMaker.Enabled { + if err := s.SpreadMaker.Bind(ctx, s.makerSession, s.Symbol); err != nil { + return err + } + } + if s.EnableArbitrage { makerMarketStream := s.makerSession.Exchange.NewStream() makerMarketStream.SetPublicOnly() @@ -2233,3 +2288,29 @@ func (s *Strategy) CrossRun( return nil } + +func isSignalSidePosition(signal float64, side types.SideType) bool { + switch side { + case types.SideTypeBuy: + return signal > 0 + + case types.SideTypeSell: + return signal < 0 + + } + + return false +} + +func getPositionProfitPrice(side types.SideType, cost, profitRatio fixedpoint.Value) fixedpoint.Value { + switch side { + case types.SideTypeBuy: + return cost.Mul(profitRatio.Add(fixedpoint.One)) + + case types.SideTypeSell: + return cost.Mul(fixedpoint.One.Sub(profitRatio)) + + } + + return cost +} diff --git a/pkg/types/order.go b/pkg/types/order.go index 1839688199..7b977e5ffe 100644 --- a/pkg/types/order.go +++ b/pkg/types/order.go @@ -290,6 +290,17 @@ type Order struct { IsIsolated bool `json:"isIsolated,omitempty" db:"is_isolated"` } +func (o *Order) GetRemainingQuantity() fixedpoint.Value { + return o.Quantity.Sub(o.ExecutedQuantity) +} + +func (o Order) AsQuery() OrderQuery { + return OrderQuery{ + Symbol: o.Symbol, + OrderID: strconv.FormatUint(o.OrderID, 10), + } +} + func (o Order) CsvHeader() []string { return []string{ "order_id", diff --git a/pkg/types/position.go b/pkg/types/position.go index 26dc8bb36c..864499a0cc 100644 --- a/pkg/types/position.go +++ b/pkg/types/position.go @@ -202,6 +202,13 @@ func (p *Position) IsDust(a ...fixedpoint.Value) bool { return p.Market.IsDustQuantity(base, price) } +func (p *Position) GetAverageCost() (averageCost fixedpoint.Value) { + p.Lock() + averageCost = p.AverageCost + p.Unlock() + return averageCost +} + // GetBase locks the mutex and return the base quantity // The base quantity can be negative func (p *Position) GetBase() (base fixedpoint.Value) { @@ -377,6 +384,16 @@ func (p *Position) Type() PositionType { return PositionClosed } +func (p *Position) Side() SideType { + if p.Base.Sign() > 0 { + return SideTypeBuy + } else if p.Base.Sign() < 0 { + return SideTypeSell + } + + return SideTypeNone +} + func (p *Position) SlackAttachment() slack.Attachment { p.Lock() defer p.Unlock() diff --git a/pkg/types/side.go b/pkg/types/side.go index 75e7a9ae1e..80b063c094 100644 --- a/pkg/types/side.go +++ b/pkg/types/side.go @@ -19,6 +19,7 @@ const ( // SideTypeBoth is only used for the configuration context SideTypeBoth = SideType("BOTH") + SideTypeNone = SideType("") ) var ErrInvalidSideType = errors.New("invalid side type") From f7a0dd4d7b72ae8878b88e14fc6f74d4970e2b00 Mon Sep 17 00:00:00 2001 From: c9s Date: Fri, 24 Jan 2025 19:24:08 +0800 Subject: [PATCH 02/12] xmaker: fix orderQuantity --- pkg/strategy/xmaker/spreadmaker.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/strategy/xmaker/spreadmaker.go b/pkg/strategy/xmaker/spreadmaker.go index 4f2f23b5cd..b2262d02fe 100644 --- a/pkg/strategy/xmaker/spreadmaker.go +++ b/pkg/strategy/xmaker/spreadmaker.go @@ -81,7 +81,9 @@ func (c *SpreadMaker) canSpreadMaking( profitPrice := getPositionProfitPrice(side, cost, c.MinProfitRatio) maxQuantity := c.MaxQuoteAmount.Div(cost) - orderQuantity := fixedpoint.Min(base, maxQuantity) + + orderQuantity := base.Abs() + orderQuantity = fixedpoint.Min(orderQuantity, maxQuantity) orderSide := side.Reverse() switch side { From fed2d5d245fb313a1663801f48c69211d97eabb8 Mon Sep 17 00:00:00 2001 From: c9s Date: Fri, 24 Jan 2025 20:11:03 +0800 Subject: [PATCH 03/12] xmaker: add SignalThreshold --- pkg/strategy/xmaker/spreadmaker.go | 19 ++++++++++++++----- pkg/strategy/xmaker/strategy.go | 6 ++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/pkg/strategy/xmaker/spreadmaker.go b/pkg/strategy/xmaker/spreadmaker.go index b2262d02fe..943392b6a6 100644 --- a/pkg/strategy/xmaker/spreadmaker.go +++ b/pkg/strategy/xmaker/spreadmaker.go @@ -2,6 +2,7 @@ package xmaker import ( "context" + "math" "sync" "time" @@ -19,6 +20,7 @@ type SpreadMaker struct { MaxQuoteAmount fixedpoint.Value `json:"maxQuoteAmount"` MaxOrderLifespan types.Duration `json:"maxOrderLifespan"` MakerOnly bool `json:"makerOnly"` + SignalThreshold float64 `json:"signalThreshold"` // order is the current spread maker order on the maker exchange order *types.Order @@ -72,27 +74,34 @@ func (c *SpreadMaker) canSpreadMaking( bestBidPrice, bestAskPrice fixedpoint.Value, // maker best bid price ) (*types.SubmitOrder, bool) { side := position.Side() + if !isSignalSidePosition(signal, side) { return nil, false } + if math.Abs(signal) < c.SignalThreshold { + return nil, false + } + base := position.GetBase() cost := position.GetAverageCost() - profitPrice := getPositionProfitPrice(side, cost, c.MinProfitRatio) + profitPrice := cost + if c.MinProfitRatio.Sign() > 0 { + profitPrice = getPositionProfitPrice(side, profitPrice, c.MinProfitRatio) + } maxQuantity := c.MaxQuoteAmount.Div(cost) - orderQuantity := base.Abs() orderQuantity = fixedpoint.Min(orderQuantity, maxQuantity) orderSide := side.Reverse() - switch side { - case types.SideTypeBuy: + switch orderSide { + case types.SideTypeSell: targetPrice := bestBidPrice.Add(makerMarket.TickSize) targetPrice = fixedpoint.Max(profitPrice, targetPrice) return c.newMakerOrder(makerMarket, orderSide, targetPrice, orderQuantity), true - case types.SideTypeSell: + case types.SideTypeBuy: targetPrice := bestAskPrice.Sub(makerMarket.TickSize) targetPrice = fixedpoint.Min(profitPrice, targetPrice) return c.newMakerOrder(makerMarket, orderSide, targetPrice, orderQuantity), true diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index 9635d6e699..475b444de1 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -1506,7 +1506,9 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { retOrder, err := s.SpreadMaker.placeOrder(ctx, makerOrderForm) if err != nil { s.logger.WithError(err).Errorf("unable to place spread maker order") - } else { + } else if retOrder != nil { + s.orderStore.Add(*retOrder) + // add covered position from the created order switch side { case types.SideTypeSell: @@ -1559,7 +1561,7 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { } if s.sourceMarket.IsDustQuantity(quantity, lastPrice) { - s.logger.Warnf("skip dust quantity: %s @ price %f", quantity.String(), lastPrice.Float64()) + s.logger.Infof("skip dust quantity: %s @ price %f", quantity.String(), lastPrice.Float64()) return } From d71007056660c0f5945b22099b164d947a4b3203 Mon Sep 17 00:00:00 2001 From: c9s Date: Fri, 24 Jan 2025 20:14:13 +0800 Subject: [PATCH 04/12] xmaker: pull out cancelSpreadMakerOrderAndReturnCoveredPos --- pkg/strategy/xmaker/strategy.go | 51 +++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index 475b444de1..d812df261c 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -1447,6 +1447,31 @@ func (s *Strategy) canDelayHedge(hedgeSide types.SideType, pos fixedpoint.Value) return false } +func (s *Strategy) cancelSpreadMakerOrderAndReturnCoveredPos(ctx context.Context) { + s.logger.Infof("canceling current spread maker order...") + + finalOrder, err := s.SpreadMaker.cancelAndQueryOrder(ctx) + if err != nil { + s.logger.WithError(err).Errorf("spread maker: cancel order error") + } + + if finalOrder != nil { + spreadMakerVolumeMetrics.With(s.metricsLabels).Add(finalOrder.ExecutedQuantity.Float64()) + spreadMakerQuoteVolumeMetrics.With(s.metricsLabels).Add(finalOrder.ExecutedQuantity.Mul(finalOrder.Price).Float64()) + + remainingQuantity := finalOrder.GetRemainingQuantity() + + s.logger.Infof("returning remaining quantity %f to the covered position", remainingQuantity.Float64()) + switch finalOrder.Side { + case types.SideTypeSell: + s.coveredPosition.Sub(remainingQuantity) + case types.SideTypeBuy: + s.coveredPosition.Add(remainingQuantity) + + } + } +} + func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { if pos.IsZero() { return @@ -1476,27 +1501,7 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { keptOrder = s.SpreadMaker.shouldKeepOrder(curOrder, now) if !keptOrder { s.logger.Infof("canceling current spread maker order...") - - finalOrder, err := s.SpreadMaker.cancelAndQueryOrder(ctx) - if err != nil { - s.logger.WithError(err).Errorf("spread maker: cancel order error") - } - - if finalOrder != nil { - spreadMakerVolumeMetrics.With(s.metricsLabels).Add(finalOrder.ExecutedQuantity.Float64()) - spreadMakerQuoteVolumeMetrics.With(s.metricsLabels).Add(finalOrder.ExecutedQuantity.Mul(finalOrder.Price).Float64()) - - remainingQuantity := finalOrder.GetRemainingQuantity() - - s.logger.Infof("returning remaining quantity %f to the covered position", remainingQuantity.Float64()) - switch finalOrder.Side { - case types.SideTypeSell: - s.coveredPosition.Sub(remainingQuantity) - case types.SideTypeBuy: - s.coveredPosition.Add(remainingQuantity) - - } - } + s.cancelSpreadMakerOrderAndReturnCoveredPos(ctx) } } @@ -1520,6 +1525,10 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { } } } + } else { + if _, hasOrder := s.SpreadMaker.getOrder(); hasOrder { + s.cancelSpreadMakerOrderAndReturnCoveredPos(ctx) + } } } } From dfd9f11c848a128dc925ef9029f72607e87dafef Mon Sep 17 00:00:00 2001 From: c9s Date: Fri, 24 Jan 2025 20:51:17 +0800 Subject: [PATCH 05/12] xmaker: add more log info for spread maker --- pkg/strategy/xmaker/spreadmaker.go | 12 ++++++++++-- pkg/strategy/xmaker/strategy.go | 14 ++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/pkg/strategy/xmaker/spreadmaker.go b/pkg/strategy/xmaker/spreadmaker.go index 943392b6a6..b9c59046e6 100644 --- a/pkg/strategy/xmaker/spreadmaker.go +++ b/pkg/strategy/xmaker/spreadmaker.go @@ -19,8 +19,12 @@ type SpreadMaker struct { MinProfitRatio fixedpoint.Value `json:"minProfitRatio"` MaxQuoteAmount fixedpoint.Value `json:"maxQuoteAmount"` MaxOrderLifespan types.Duration `json:"maxOrderLifespan"` - MakerOnly bool `json:"makerOnly"` - SignalThreshold float64 `json:"signalThreshold"` + + SignalThreshold float64 `json:"signalThreshold"` + + ReverseSignalOrderCancel bool `json:"reverseSignalOrderCancel"` + + MakerOnly bool `json:"makerOnly"` // order is the current spread maker order on the maker exchange order *types.Order @@ -160,6 +164,10 @@ func (c *SpreadMaker) cancelAndQueryOrder(ctx context.Context) (*types.Order, er return nil, err } + c.mu.Lock() + c.order = nil + c.mu.Unlock() + finalOrder, err := retry.QueryOrderUntilCanceled(ctx, c.orderQueryService, c.order.Symbol, c.order.OrderID) if err != nil { return nil, err diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index d812df261c..7c069abe68 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -1486,12 +1486,16 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { signal := s.lastAggregatedSignal.Get() if s.SpreadMaker != nil && s.SpreadMaker.Enabled && s.makerBook != nil { - if makerBid, makerAsk, hasPrice := s.makerBook.BestBidAndAsk(); hasPrice { + if makerBid, makerAsk, hasMakerPrice := s.makerBook.BestBidAndAsk(); hasMakerPrice { if makerOrderForm, ok := s.SpreadMaker.canSpreadMaking(signal, s.Position, s.makerMarket, makerBid.Price, makerAsk.Price); ok { - spreadMakerCounterMetrics.With(s.metricsLabels).Inc() - s.logger.Infof("spread maker order form: %+v", makerOrderForm) + s.logger.Infof("position: %f@%f, maker book bid: %f/%f, spread maker order form: %+v", + s.Position.GetBase().Float64(), + s.Position.GetAverageCost().Float64(), + makerAsk.Price.Float64(), + makerBid.Price.Float64(), + makerOrderForm) // if we have the existing order, cancel it and return the covered position // keptOrder means we kept the current order and we don't need to place a new order @@ -1514,6 +1518,8 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { } else if retOrder != nil { s.orderStore.Add(*retOrder) + s.logger.Infof("spread maker order placed: #%d %f@%f (%s)", retOrder.OrderID, retOrder.Quantity.Float64(), retOrder.Price.Float64(), retOrder.Status) + // add covered position from the created order switch side { case types.SideTypeSell: @@ -1525,7 +1531,7 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { } } } - } else { + } else if s.SpreadMaker.ReverseSignalOrderCancel { if _, hasOrder := s.SpreadMaker.getOrder(); hasOrder { s.cancelSpreadMakerOrderAndReturnCoveredPos(ctx) } From 2f48af5f6837125f129d079ec4d457a8a00c4d53 Mon Sep 17 00:00:00 2001 From: c9s Date: Fri, 24 Jan 2025 22:24:58 +0800 Subject: [PATCH 06/12] xmaker: save order object --- pkg/strategy/xmaker/spreadmaker.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/strategy/xmaker/spreadmaker.go b/pkg/strategy/xmaker/spreadmaker.go index b9c59046e6..0547183f29 100644 --- a/pkg/strategy/xmaker/spreadmaker.go +++ b/pkg/strategy/xmaker/spreadmaker.go @@ -165,10 +165,11 @@ func (c *SpreadMaker) cancelAndQueryOrder(ctx context.Context) (*types.Order, er } c.mu.Lock() + order := c.order c.order = nil c.mu.Unlock() - finalOrder, err := retry.QueryOrderUntilCanceled(ctx, c.orderQueryService, c.order.Symbol, c.order.OrderID) + finalOrder, err := retry.QueryOrderUntilCanceled(ctx, c.orderQueryService, order.Symbol, order.OrderID) if err != nil { return nil, err } From 24b676bc3c96bd0fa62f38bf4bcc6286e8feb358 Mon Sep 17 00:00:00 2001 From: c9s Date: Fri, 24 Jan 2025 22:55:27 +0800 Subject: [PATCH 07/12] xmaker: consider maker fee rate --- pkg/strategy/xmaker/spreadmaker.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/strategy/xmaker/spreadmaker.go b/pkg/strategy/xmaker/spreadmaker.go index 0547183f29..f1f5aebd94 100644 --- a/pkg/strategy/xmaker/spreadmaker.go +++ b/pkg/strategy/xmaker/spreadmaker.go @@ -89,10 +89,7 @@ func (c *SpreadMaker) canSpreadMaking( base := position.GetBase() cost := position.GetAverageCost() - profitPrice := cost - if c.MinProfitRatio.Sign() > 0 { - profitPrice = getPositionProfitPrice(side, profitPrice, c.MinProfitRatio) - } + profitPrice := getPositionProfitPrice(side, cost, c.session.MakerFeeRate.Add(c.MinProfitRatio)) maxQuantity := c.MaxQuoteAmount.Div(cost) orderQuantity := base.Abs() From 9332b83cc102bf9e41573523cb45e9f6923c85dd Mon Sep 17 00:00:00 2001 From: c9s Date: Fri, 24 Jan 2025 23:07:05 +0800 Subject: [PATCH 08/12] xmaker: add MakerOnly option --- pkg/strategy/xmaker/strategy.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index 7c069abe68..c068907845 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -155,6 +155,7 @@ type Strategy struct { UseDepthPrice bool `json:"useDepthPrice"` DepthQuantity fixedpoint.Value `json:"depthQuantity"` SourceDepthLevel types.Depth `json:"sourceDepthLevel"` + MakerOnly bool `json:"makerOnly"` // EnableDelayHedge enables the delay hedge feature EnableDelayHedge bool `json:"enableDelayHedge"` @@ -1037,6 +1038,11 @@ func (s *Strategy) updateQuote(ctx context.Context) error { return nil } + var orderType = types.OrderTypeLimit + if s.MakerOnly { + orderType = types.OrderTypeLimitMaker + } + var submitOrders []types.SubmitOrder var accumulativeBidQuantity, accumulativeAskQuantity fixedpoint.Value @@ -1122,7 +1128,7 @@ func (s *Strategy) updateQuote(ctx context.Context) error { submitOrders = append(submitOrders, types.SubmitOrder{ Symbol: s.Symbol, Market: s.makerMarket, - Type: types.OrderTypeLimit, + Type: orderType, Side: types.SideTypeBuy, Price: bidPrice, Quantity: bidQuantity, @@ -1181,7 +1187,7 @@ func (s *Strategy) updateQuote(ctx context.Context) error { submitOrders = append(submitOrders, types.SubmitOrder{ Symbol: s.Symbol, Market: s.makerMarket, - Type: types.OrderTypeLimit, + Type: orderType, Side: types.SideTypeSell, Price: askPrice, Quantity: askQuantity, From 79232e7b73fa48947de32ce2ac71bc08e42d4353 Mon Sep 17 00:00:00 2001 From: c9s Date: Sat, 25 Jan 2025 08:14:48 +0800 Subject: [PATCH 09/12] xmaker: set mmr --- pkg/strategy/xmaker/strategy.go | 63 +++++++++++++++++++++------- pkg/strategy/xmaker/strategy_test.go | 4 +- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index c068907845..639ea9a288 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -678,13 +678,35 @@ func (s *Strategy) getLayerPrice( return price } -// margin level = totalValue / totalDebtValue -func calculateDebtQuota(totalValue, debtValue, minMarginLevel fixedpoint.Value) fixedpoint.Value { +// margin level = totalValue / totalDebtValue * MMR (maintenance margin ratio) +// on binance: +// - MMR with 10x leverage = 5% +// - MMR with 5x leverage = 9% +// - MMR with 3x leverage = 10% +func calculateDebtQuota(totalValue, debtValue, minMarginLevel, leverage fixedpoint.Value) fixedpoint.Value { if minMarginLevel.IsZero() || totalValue.IsZero() { return fixedpoint.Zero } - debtCap := totalValue.Div(minMarginLevel) + defaultMmr := fixedpoint.NewFromFloat(9.0 * 0.01) + if leverage.Compare(fixedpoint.NewFromFloat(10.0)) >= 0 { + defaultMmr = fixedpoint.NewFromFloat(5.0 * 0.01) // 5% + } else if leverage.Compare(fixedpoint.NewFromFloat(5.0)) >= 0 { + defaultMmr = fixedpoint.NewFromFloat(9.0 * 0.01) // 9% + } else if leverage.Compare(fixedpoint.NewFromFloat(3.0)) >= 0 { + defaultMmr = fixedpoint.NewFromFloat(10.0 * 0.01) // 10% + } + + debtCap := totalValue.Div(minMarginLevel).Div(defaultMmr) + marginLevel := totalValue.Div(debtValue).Div(defaultMmr) + + log.Infof("calculateDebtQuota: debtCap=%f, debtValue=%f currentMarginLevel=%f mmr=%f", + debtCap.Float64(), + debtValue.Float64(), + marginLevel.Float64(), + defaultMmr.Float64(), + ) + debtQuota := debtCap.Sub(debtValue) if debtQuota.Sign() < 0 { return fixedpoint.Zero @@ -693,7 +715,7 @@ func calculateDebtQuota(totalValue, debtValue, minMarginLevel fixedpoint.Value) return debtQuota } -func (s *Strategy) allowMarginHedge(side types.SideType) (bool, fixedpoint.Value) { +func (s *Strategy) allowMarginHedge(makerSide types.SideType) (bool, fixedpoint.Value) { zero := fixedpoint.Zero if !s.sourceSession.Margin { @@ -712,15 +734,22 @@ func (s *Strategy) allowMarginHedge(side types.SideType) (bool, fixedpoint.Value marketValue := s.accountValueCalculator.MarketValue() debtValue := s.accountValueCalculator.DebtValue() netValueInUsd := s.accountValueCalculator.NetValue() - s.logger.Infof("hedge account net value in usd: %f, debt value in usd: %f", + s.logger.Infof("hedge account net value in usd: %f, debt value in usd: %f, total value in usd: %f", netValueInUsd.Float64(), - debtValue.Float64()) + debtValue.Float64(), + marketValue.Float64(), + ) // if the margin level is higher than the minimal margin level, // we can hedge the position, but we need to check the debt quota if hedgeAccount.MarginLevel.Compare(s.MinMarginLevel) > 0 { + // debtQuota is the quota with minimal margin level - debtQuota := calculateDebtQuota(marketValue, debtValue, bufMinMarginLevel) + debtQuota := calculateDebtQuota(marketValue, debtValue, bufMinMarginLevel, s.MaxHedgeAccountLeverage) + + s.logger.Infof("hedge account margin level %f > %f, debt quota: %f", + hedgeAccount.MarginLevel.Float64(), s.MinMarginLevel.Float64(), debtQuota.Float64()) + if debtQuota.Sign() <= 0 { return false, zero } @@ -738,7 +767,7 @@ func (s *Strategy) allowMarginHedge(side types.SideType) (bool, fixedpoint.Value debtQuota = fixedpoint.Min(debtQuota, leverageQuotaInUsd) } - switch side { + switch makerSide { case types.SideTypeBuy: return true, debtQuota @@ -753,7 +782,7 @@ func (s *Strategy) allowMarginHedge(side types.SideType) (bool, fixedpoint.Value return true, zero } - // side here is the side of maker + // makerSide here is the makerSide of maker // if the margin level is too low, check if we can hedge the position with repayments to reduce the position quoteBal, ok := hedgeAccount.Balance(s.sourceMarket.QuoteCurrency) if !ok { @@ -765,7 +794,7 @@ func (s *Strategy) allowMarginHedge(side types.SideType) (bool, fixedpoint.Value baseBal = types.NewZeroBalance(s.sourceMarket.BaseCurrency) } - switch side { + switch makerSide { case types.SideTypeBuy: if baseBal.Available.IsZero() { return false, zero @@ -850,7 +879,7 @@ func (s *Strategy) updateQuote(ctx context.Context) error { bestBid, bestAsk, hasPrice := s.sourceBook.BestBidAndAsk() if !hasPrice { s.logger.Warnf("no valid price, skip quoting") - return fmt.Errorf("no valid book price") + return nil } bestBidPrice := bestBid.Price @@ -954,7 +983,7 @@ func (s *Strategy) updateQuote(ctx context.Context) error { !hedgeAccount.MarginLevel.IsZero() { if hedgeAccount.MarginLevel.Compare(s.MinMarginLevel) < 0 { - s.logger.Infof("hedge account margin level %s is less then the min margin level %s, calculating the borrowed positions", + s.logger.Warnf("hedge account margin level %s is less then the min margin level %s, calculating the borrowed positions", hedgeAccount.MarginLevel.String(), s.MinMarginLevel.String()) } else { @@ -963,17 +992,19 @@ func (s *Strategy) updateQuote(ctx context.Context) error { s.MinMarginLevel.String()) } - allowMarginBuy, bidQuota := s.allowMarginHedge(types.SideTypeBuy) - if allowMarginBuy { + allowMarginSell, bidQuota := s.allowMarginHedge(types.SideTypeBuy) + if allowMarginSell { hedgeQuota.BaseAsset.Add(bidQuota.Div(bestBid.Price)) } else { + s.logger.Warnf("margin hedge sell is disabled, disabling maker bid orders...") disableMakerBid = true } - allowMarginSell, sellQuota := s.allowMarginHedge(types.SideTypeSell) - if allowMarginSell { + allowMarginBuy, sellQuota := s.allowMarginHedge(types.SideTypeSell) + if allowMarginBuy { hedgeQuota.QuoteAsset.Add(sellQuota.Mul(bestAsk.Price)) } else { + s.logger.Warnf("margin hedge buy is disabled, disabling maker ask orders...") disableMakerAsk = true } } else { diff --git a/pkg/strategy/xmaker/strategy_test.go b/pkg/strategy/xmaker/strategy_test.go index 0a067debf8..1c6265e986 100644 --- a/pkg/strategy/xmaker/strategy_test.go +++ b/pkg/strategy/xmaker/strategy_test.go @@ -68,12 +68,12 @@ func TestStrategy_allowMarginHedge(t *testing.T) { allowed, quota := s.allowMarginHedge(types.SideTypeBuy) if assert.True(t, allowed) { - assert.InDelta(t, 133782.26785814, quota.Float64(), 1.0, "should be able to borrow %f USDT", quota.Float64()) + assert.InDelta(t, 2.47735853175711e+06, quota.Float64(), 0.0001, "should be able to borrow %f USDT", quota.Float64()) } allowed, quota = s.allowMarginHedge(types.SideTypeSell) if assert.True(t, allowed) { - assert.InDelta(t, 1.36512518, quota.Float64(), 0.0001, "should be able to borrow %f BTC", quota.Float64()) + assert.InDelta(t, 25.27916869, quota.Float64(), 0.0001, "should be able to borrow %f BTC", quota.Float64()) } }) From 9d29612d6d8c91258d57810e08feb22fd6be0bef Mon Sep 17 00:00:00 2001 From: c9s Date: Tue, 28 Jan 2025 11:26:43 +0800 Subject: [PATCH 10/12] xmaker: fix order check condition --- pkg/strategy/xmaker/strategy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index 639ea9a288..0d8f249be1 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -1546,7 +1546,7 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { } } - if !keptOrder { + if !hasOrder || !keptOrder { s.logger.Infof("placing new spread maker order: %+v...", makerOrderForm) retOrder, err := s.SpreadMaker.placeOrder(ctx, makerOrderForm) From 48fe6e563a1bb83204887fd1518633fd0c9c7248 Mon Sep 17 00:00:00 2001 From: c9s Date: Tue, 28 Jan 2025 11:43:10 +0800 Subject: [PATCH 11/12] xmaker: count spread maker only when placing new order --- pkg/strategy/xmaker/strategy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index 0d8f249be1..3ff6ec0819 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -1525,7 +1525,6 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { if s.SpreadMaker != nil && s.SpreadMaker.Enabled && s.makerBook != nil { if makerBid, makerAsk, hasMakerPrice := s.makerBook.BestBidAndAsk(); hasMakerPrice { if makerOrderForm, ok := s.SpreadMaker.canSpreadMaking(signal, s.Position, s.makerMarket, makerBid.Price, makerAsk.Price); ok { - spreadMakerCounterMetrics.With(s.metricsLabels).Inc() s.logger.Infof("position: %f@%f, maker book bid: %f/%f, spread maker order form: %+v", s.Position.GetBase().Float64(), @@ -1547,6 +1546,7 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { } if !hasOrder || !keptOrder { + spreadMakerCounterMetrics.With(s.metricsLabels).Inc() s.logger.Infof("placing new spread maker order: %+v...", makerOrderForm) retOrder, err := s.SpreadMaker.placeOrder(ctx, makerOrderForm) From e1f0b1657e2fe8974af336ef9ece6d8f1cc49eed Mon Sep 17 00:00:00 2001 From: c9s Date: Tue, 28 Jan 2025 11:46:02 +0800 Subject: [PATCH 12/12] xmkaer: pass coveredPosition as a reference --- pkg/strategy/xmaker/strategy.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index 3ff6ec0819..9fb849268d 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -1484,7 +1484,7 @@ func (s *Strategy) canDelayHedge(hedgeSide types.SideType, pos fixedpoint.Value) return false } -func (s *Strategy) cancelSpreadMakerOrderAndReturnCoveredPos(ctx context.Context) { +func (s *Strategy) cancelSpreadMakerOrderAndReturnCoveredPos(ctx context.Context, coveredPosition *fixedpoint.MutexValue) { s.logger.Infof("canceling current spread maker order...") finalOrder, err := s.SpreadMaker.cancelAndQueryOrder(ctx) @@ -1501,9 +1501,9 @@ func (s *Strategy) cancelSpreadMakerOrderAndReturnCoveredPos(ctx context.Context s.logger.Infof("returning remaining quantity %f to the covered position", remainingQuantity.Float64()) switch finalOrder.Side { case types.SideTypeSell: - s.coveredPosition.Sub(remainingQuantity) + coveredPosition.Sub(remainingQuantity) case types.SideTypeBuy: - s.coveredPosition.Add(remainingQuantity) + coveredPosition.Add(remainingQuantity) } } @@ -1541,7 +1541,7 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { keptOrder = s.SpreadMaker.shouldKeepOrder(curOrder, now) if !keptOrder { s.logger.Infof("canceling current spread maker order...") - s.cancelSpreadMakerOrderAndReturnCoveredPos(ctx) + s.cancelSpreadMakerOrderAndReturnCoveredPos(ctx, &s.coveredPosition) } } @@ -1570,7 +1570,7 @@ func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { } } else if s.SpreadMaker.ReverseSignalOrderCancel { if _, hasOrder := s.SpreadMaker.getOrder(); hasOrder { - s.cancelSpreadMakerOrderAndReturnCoveredPos(ctx) + s.cancelSpreadMakerOrderAndReturnCoveredPos(ctx, &s.coveredPosition) } } }