From 39fad2e0b516f7782ee8ec8f813b475c2b79b508 Mon Sep 17 00:00:00 2001 From: c9s Date: Mon, 30 Sep 2024 16:21:22 +0800 Subject: [PATCH 1/2] xmaker: add depth ratio signal --- pkg/strategy/xmaker/signal_book.go | 4 + pkg/strategy/xmaker/signal_depth.go | 80 ++++++++++++++++ pkg/strategy/xmaker/signal_depth_test.go | 113 +++++++++++++++++++++++ pkg/strategy/xmaker/strategy.go | 8 ++ pkg/types/price_volume_slice.go | 22 +++++ 5 files changed, 227 insertions(+) create mode 100644 pkg/strategy/xmaker/signal_depth.go create mode 100644 pkg/strategy/xmaker/signal_depth_test.go diff --git a/pkg/strategy/xmaker/signal_book.go b/pkg/strategy/xmaker/signal_book.go index ac7288e51..4ab19a003 100644 --- a/pkg/strategy/xmaker/signal_book.go +++ b/pkg/strategy/xmaker/signal_book.go @@ -29,6 +29,10 @@ type OrderBookBestPriceVolumeSignal struct { book *types.StreamOrderBook } +func (s *OrderBookBestPriceVolumeSignal) BindStreamBook(book *types.StreamOrderBook) { + s.book = book +} + func (s *OrderBookBestPriceVolumeSignal) Bind(ctx context.Context, session *bbgo.ExchangeSession, symbol string) error { if s.book == nil { return errors.New("s.book can not be nil") diff --git a/pkg/strategy/xmaker/signal_depth.go b/pkg/strategy/xmaker/signal_depth.go new file mode 100644 index 000000000..6cab13faa --- /dev/null +++ b/pkg/strategy/xmaker/signal_depth.go @@ -0,0 +1,80 @@ +package xmaker + +import ( + "context" + "math" + + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +var depthRatioSignalMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "xmaker_depth_ratio_signal", + Help: "", + }, []string{"symbol"}) + +func init() { + prometheus.MustRegister(depthRatioSignalMetrics) +} + +type DepthRatioSignal struct { + // PriceRange, 2% depth ratio means 2% price range from the mid price + PriceRange fixedpoint.Value `json:"priceRange"` + MinRatio float64 `json:"minRatio"` + + symbol string + book *types.StreamOrderBook +} + +func (s *DepthRatioSignal) BindStreamBook(book *types.StreamOrderBook) { + s.book = book +} + +func (s *DepthRatioSignal) Bind(ctx context.Context, session *bbgo.ExchangeSession, symbol string) error { + if s.book == nil { + return errors.New("s.book can not be nil") + } + + s.symbol = symbol + orderBookSignalMetrics.WithLabelValues(s.symbol).Set(0.0) + return nil +} + +func (s *DepthRatioSignal) CalculateSignal(ctx context.Context) (float64, error) { + bid, ask, ok := s.book.BestBidAndAsk() + if !ok { + return 0.0, nil + } + + midPrice := bid.Price.Add(ask.Price).Div(fixedpoint.Two) + + asks := s.book.SideBook(types.SideTypeSell) + bids := s.book.SideBook(types.SideTypeBuy) + + asksInRange := asks.InPriceRange(midPrice, types.SideTypeSell, s.PriceRange) + bidsInRange := bids.InPriceRange(midPrice, types.SideTypeBuy, s.PriceRange) + + askDepthQuote := asksInRange.SumDepthInQuote() + bidDepthQuote := bidsInRange.SumDepthInQuote() + + var signal = 0.0 + + depthRatio := bidDepthQuote.Div(askDepthQuote.Add(bidDepthQuote)) + + // convert ratio into -2.0 and 2.0 + signal = depthRatio.Sub(fixedpoint.NewFromFloat(0.5)).Float64() * 4.0 + + // ignore noise + if math.Abs(signal) < s.MinRatio { + signal = 0.0 + } + + log.Infof("[DepthRatioSignal] %f bid/ask = %f/%f", signal, bidDepthQuote.Float64(), askDepthQuote.Float64()) + depthRatioSignalMetrics.WithLabelValues(s.symbol).Set(signal) + return signal, nil +} diff --git a/pkg/strategy/xmaker/signal_depth_test.go b/pkg/strategy/xmaker/signal_depth_test.go new file mode 100644 index 000000000..3f8e435f1 --- /dev/null +++ b/pkg/strategy/xmaker/signal_depth_test.go @@ -0,0 +1,113 @@ +package xmaker + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + + . "github.com/c9s/bbgo/pkg/testing/testhelper" +) + +func TestDepthRatioSignal_CalculateSignal(t *testing.T) { + type fields struct { + PriceRange fixedpoint.Value + MinRatio float64 + symbol string + book *types.StreamOrderBook + } + type args struct { + ctx context.Context + bids, asks types.PriceVolumeSlice + } + + tests := []struct { + name string + fields fields + args args + want float64 + wantErr assert.ErrorAssertionFunc + }{ + { + name: "test1", + fields: fields{ + PriceRange: fixedpoint.NewFromFloat(0.02), + MinRatio: 0.01, + symbol: "BTCUSDT", + }, + args: args{ + ctx: context.Background(), + asks: PriceVolumeSliceFromText(` + 19310,1.0 + 19320,0.2 + 19330,0.3 + 19340,0.4 + 19350,0.5 + `), + bids: PriceVolumeSliceFromText(` + 19300,0.1 + 19290,0.2 + 19280,0.3 + 19270,0.4 + 19260,0.5 + `), + }, + want: -0.4641, + wantErr: assert.NoError, + }, + { + name: "normal", + fields: fields{ + PriceRange: fixedpoint.NewFromFloat(0.02), + MinRatio: 0.01, + symbol: "BTCUSDT", + }, + args: args{ + ctx: context.Background(), + asks: PriceVolumeSliceFromText(` + 19310,0.1 + 19320,0.2 + 19330,0.3 + 19340,0.4 + 19350,0.5 + `), + bids: PriceVolumeSliceFromText(` + 19300,0.1 + 19290,0.2 + 19280,0.3 + 19270,0.4 + 19260,0.5 + `), + }, + want: 0, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &DepthRatioSignal{ + PriceRange: tt.fields.PriceRange, + MinRatio: tt.fields.MinRatio, + symbol: tt.fields.symbol, + book: types.NewStreamBook("BTCUSDT", types.ExchangeBinance), + } + + s.book.Load(types.SliceOrderBook{ + Symbol: "BTCUSDT", + Bids: tt.args.bids, + Asks: tt.args.asks, + }) + + got, err := s.CalculateSignal(tt.args.ctx) + if !tt.wantErr(t, err, fmt.Sprintf("CalculateSignal(%v)", tt.args.ctx)) { + return + } + + assert.InDeltaf(t, tt.want, got, 0.001, "CalculateSignal(%v)", tt.args.ctx) + }) + } +} diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index 154f227e7..10ee5ecf1 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -64,6 +64,7 @@ type SignalConfig struct { Weight float64 `json:"weight"` BollingerBandTrendSignal *BollingerBandTrendSignal `json:"bollingerBandTrend,omitempty"` OrderBookBestPriceSignal *OrderBookBestPriceVolumeSignal `json:"orderBookBestPrice,omitempty"` + DepthRatioSignal *DepthRatioSignal `json:"depthRatio,omitempty"` KLineShapeSignal *KLineShapeSignal `json:"klineShape,omitempty"` TradeVolumeWindowSignal *TradeVolumeWindowSignal `json:"tradeVolumeWindow,omitempty"` } @@ -390,6 +391,8 @@ func (s *Strategy) aggregateSignal(ctx context.Context) (float64, error) { var err error if signal.OrderBookBestPriceSignal != nil { sig, err = signal.OrderBookBestPriceSignal.CalculateSignal(ctx) + } else if signal.DepthRatioSignal != nil { + sig, err = signal.DepthRatioSignal.CalculateSignal(ctx) } else if signal.BollingerBandTrendSignal != nil { sig, err = signal.BollingerBandTrendSignal.CalculateSignal(ctx) } else if signal.TradeVolumeWindowSignal != nil { @@ -1547,6 +1550,11 @@ func (s *Strategy) CrossRun( if err := signalConfig.OrderBookBestPriceSignal.Bind(ctx, s.sourceSession, s.Symbol); err != nil { return err } + } else if signalConfig.DepthRatioSignal != nil { + signalConfig.DepthRatioSignal.book = s.sourceBook + if err := signalConfig.DepthRatioSignal.Bind(ctx, s.sourceSession, s.Symbol); err != nil { + return err + } } else if signalConfig.BollingerBandTrendSignal != nil { if err := signalConfig.BollingerBandTrendSignal.Bind(ctx, s.sourceSession, s.Symbol); err != nil { return err diff --git a/pkg/types/price_volume_slice.go b/pkg/types/price_volume_slice.go index 2f51320e0..7f03941f6 100644 --- a/pkg/types/price_volume_slice.go +++ b/pkg/types/price_volume_slice.go @@ -280,6 +280,28 @@ func (slice PriceVolumeSlice) AverageDepthPriceByQuote(requiredDepthInQuote fixe return totalQuoteAmount.Div(totalQuantity) } +func (slice PriceVolumeSlice) InPriceRange(midPrice fixedpoint.Value, side SideType, r fixedpoint.Value) (sub PriceVolumeSlice) { + switch side { + case SideTypeSell: + boundaryPrice := midPrice.Add(midPrice.Mul(r)) + for _, pv := range slice { + if pv.Price.Compare(boundaryPrice) <= 0 { + sub = append(sub, pv) + } + } + + case SideTypeBuy: + boundaryPrice := midPrice.Sub(midPrice.Mul(r)) + for _, pv := range slice { + if pv.Price.Compare(boundaryPrice) >= 0 { + sub = append(sub, pv) + } + } + } + + return sub +} + // AverageDepthPrice uses the required total quantity to calculate the corresponding price func (slice PriceVolumeSlice) AverageDepthPrice(requiredQuantity fixedpoint.Value) fixedpoint.Value { // rest quantity From 3c486630326c5c0ff0fc03b8d47e326567052084 Mon Sep 17 00:00:00 2001 From: c9s Date: Mon, 30 Sep 2024 17:32:46 +0800 Subject: [PATCH 2/2] add more tests --- pkg/strategy/xmaker/signal_depth_test.go | 56 +++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/pkg/strategy/xmaker/signal_depth_test.go b/pkg/strategy/xmaker/signal_depth_test.go index 3f8e435f1..c11654342 100644 --- a/pkg/strategy/xmaker/signal_depth_test.go +++ b/pkg/strategy/xmaker/signal_depth_test.go @@ -33,7 +33,7 @@ func TestDepthRatioSignal_CalculateSignal(t *testing.T) { wantErr assert.ErrorAssertionFunc }{ { - name: "test1", + name: "medium short", fields: fields{ PriceRange: fixedpoint.NewFromFloat(0.02), MinRatio: 0.01, @@ -59,6 +59,60 @@ func TestDepthRatioSignal_CalculateSignal(t *testing.T) { want: -0.4641, wantErr: assert.NoError, }, + { + name: "strong short", + fields: fields{ + PriceRange: fixedpoint.NewFromFloat(0.02), + MinRatio: 0.01, + symbol: "BTCUSDT", + }, + args: args{ + ctx: context.Background(), + asks: PriceVolumeSliceFromText(` + 19310,10.0 + 19320,0.2 + 19330,0.3 + 19340,0.4 + 19350,0.5 + `), + bids: PriceVolumeSliceFromText(` + 19300,0.1 + 19290,0.1 + 19280,0.1 + 19270,0.1 + 19260,0.1 + `), + }, + want: -1.8322, + wantErr: assert.NoError, + }, + { + name: "strong long", + fields: fields{ + PriceRange: fixedpoint.NewFromFloat(0.02), + MinRatio: 0.01, + symbol: "BTCUSDT", + }, + args: args{ + ctx: context.Background(), + asks: PriceVolumeSliceFromText(` + 19310,0.1 + 19320,0.1 + 19330,0.1 + 19340,0.1 + 19350,0.1 + `), + bids: PriceVolumeSliceFromText(` + 19300,10.0 + 19290,0.1 + 19280,0.1 + 19270,0.1 + 19260,0.1 + `), + }, + want: 1.81623, + wantErr: assert.NoError, + }, { name: "normal", fields: fields{