From 9ebab4f4f7f5a6940cdbde945fe1a14fdd8d988d Mon Sep 17 00:00:00 2001 From: c9s Date: Fri, 30 Aug 2024 15:44:55 +0800 Subject: [PATCH] xmaker: add signal providers --- pkg/strategy/xmaker/metrics.go | 7 ++ pkg/strategy/xmaker/signal_boll.go | 87 ++++++++++++++++ pkg/strategy/xmaker/signal_book.go | 70 +++++++++++++ pkg/strategy/xmaker/strategy.go | 154 +++++++++++++++++++++++------ 4 files changed, 289 insertions(+), 29 deletions(-) create mode 100644 pkg/strategy/xmaker/signal_boll.go create mode 100644 pkg/strategy/xmaker/signal_book.go diff --git a/pkg/strategy/xmaker/metrics.go b/pkg/strategy/xmaker/metrics.go index a05743d19..9fb070d1e 100644 --- a/pkg/strategy/xmaker/metrics.go +++ b/pkg/strategy/xmaker/metrics.go @@ -38,6 +38,12 @@ var askMarginMetrics = prometheus.NewGaugeVec( Help: "the current ask margin (dynamic)", }, []string{"strategy_type", "strategy_id", "exchange", "symbol"}) +var finalSignalMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "xmaker_final_signal", + Help: "", + }, []string{"strategy_type", "strategy_id", "exchange", "symbol"}) + var configNumOfLayersMetrics = prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "xmaker_config_num_of_layers", @@ -70,6 +76,7 @@ func init() { makerBestAskPriceMetrics, bidMarginMetrics, askMarginMetrics, + finalSignalMetrics, configNumOfLayersMetrics, configMaxExposureMetrics, configBidMarginMetrics, diff --git a/pkg/strategy/xmaker/signal_boll.go b/pkg/strategy/xmaker/signal_boll.go new file mode 100644 index 000000000..dedb03c56 --- /dev/null +++ b/pkg/strategy/xmaker/signal_boll.go @@ -0,0 +1,87 @@ +package xmaker + +import ( + "context" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/indicator/v2" + "github.com/c9s/bbgo/pkg/types" +) + +var bollingerBandSignalMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "xmaker_bollinger_band_signal", + Help: "", + }, []string{"symbol"}) + +func init() { + prometheus.MustRegister(bollingerBandSignalMetrics) +} + +type BollingerBandTrendSignal struct { + types.IntervalWindow + MinBandWidth float64 `json:"minBandWidth"` + MaxBandWidth float64 `json:"maxBandWidth"` + + indicator *indicatorv2.BOLLStream + symbol string + lastK *types.KLine +} + +func (s *BollingerBandTrendSignal) Bind(ctx context.Context, session *bbgo.ExchangeSession, symbol string) error { + if s.MaxBandWidth == 0.0 { + s.MaxBandWidth = 2.0 + } + + if s.MinBandWidth == 0.0 { + s.MinBandWidth = 1.0 + } + + s.symbol = symbol + s.indicator = session.Indicators(symbol).BOLL(s.IntervalWindow, s.MinBandWidth) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.symbol, s.IntervalWindow.Interval, func(kline types.KLine) { + s.lastK = &kline + })) + + bollingerBandSignalMetrics.WithLabelValues(s.symbol).Set(0.0) + return nil +} + +func (s *BollingerBandTrendSignal) CalculateSignal(ctx context.Context) (float64, error) { + if s.lastK == nil { + return 0, nil + } + + closePrice := s.lastK.Close + + // when bid price is lower than the down band, then it's in the downtrend + // when ask price is higher than the up band, then it's in the uptrend + lastDownBand := fixedpoint.NewFromFloat(s.indicator.DownBand.Last(0)) + lastUpBand := fixedpoint.NewFromFloat(s.indicator.UpBand.Last(0)) + + log.Infof("bollinger band: up/down = %f/%f, close price = %f", + lastUpBand.Float64(), + lastDownBand.Float64(), + closePrice.Float64()) + + // if the price is inside the band, do not vote + if closePrice.Compare(lastDownBand) > 0 && closePrice.Compare(lastUpBand) < 0 { + return 0.0, nil + } + + maxBandWidth := s.indicator.StdDev.Last(0) * s.MaxBandWidth + + signal := 0.0 + if closePrice.Compare(lastDownBand) < 0 { + signal = lastDownBand.Sub(closePrice).Float64() / maxBandWidth * -2.0 + } else if closePrice.Compare(lastUpBand) > 0 { + signal = closePrice.Sub(lastUpBand).Float64() / maxBandWidth * 2.0 + } + + log.Infof("bollinger signal: %f", signal) + return signal, nil +} diff --git a/pkg/strategy/xmaker/signal_book.go b/pkg/strategy/xmaker/signal_book.go new file mode 100644 index 000000000..ee1cb9f76 --- /dev/null +++ b/pkg/strategy/xmaker/signal_book.go @@ -0,0 +1,70 @@ +package xmaker + +import ( + "context" + + "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 orderBookSignalMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "xmaker_order_book_signal", + Help: "", + }, []string{"symbol"}) + +func init() { + prometheus.MustRegister(orderBookSignalMetrics) +} + +type OrderBookBestPriceVolumeSignal struct { + RatioThreshold fixedpoint.Value `json:"ratioThreshold"` + MinVolume fixedpoint.Value `json:"minVolume"` + + symbol string + book *types.StreamOrderBook +} + +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") + } + + s.symbol = symbol + orderBookSignalMetrics.WithLabelValues(s.symbol).Set(0.0) + return nil +} + +func (s *OrderBookBestPriceVolumeSignal) CalculateSignal(ctx context.Context) (float64, error) { + bid, ask, ok := s.book.BestBidAndAsk() + if !ok { + return 0.0, nil + } + + if bid.Volume.Compare(s.MinVolume) < 0 && ask.Volume.Compare(s.MinVolume) < 0 { + return 0.0, nil + } + + log.Infof("OrderBookBestPriceVolumeSignal: bid/ask = %f/%f", bid.Volume.Float64(), ask.Volume.Float64()) + + // TODO: may use scale to define this + sumVol := bid.Volume.Add(ask.Volume) + bidRatio := bid.Volume.Div(sumVol) + askRatio := ask.Volume.Div(sumVol) + denominator := fixedpoint.One.Sub(s.RatioThreshold) + signal := 0.0 + if bidRatio.Compare(s.RatioThreshold) >= 0 { + numerator := bidRatio.Sub(s.RatioThreshold) + signal = numerator.Div(denominator).Float64() + } else if askRatio.Compare(s.RatioThreshold) >= 0 { + numerator := askRatio.Sub(s.RatioThreshold) + signal = -numerator.Div(denominator).Float64() + } + + orderBookSignalMetrics.WithLabelValues(s.symbol).Set(signal) + return signal, nil +} diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index 52dec31f5..d51628902 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -31,6 +31,41 @@ const ID = "xmaker" var log = logrus.WithField("strategy", ID) +type Quote struct { + BestBidPrice, BestAskPrice fixedpoint.Value + + BidMargin, AskMargin fixedpoint.Value + + // BidLayerPips is the price pips between each layer + BidLayerPips, AskLayerPips fixedpoint.Value +} + +type SessionBinder interface { + Bind(ctx context.Context, session *bbgo.ExchangeSession, symbol string) error +} + +type SignalNumber float64 + +const ( + SignalNumberMaxLong = 2.0 + SignalNumberMaxShort = -2.0 +) + +type SignalProvider interface { + CalculateSignal(ctx context.Context) (float64, error) +} + +type KLineShapeSignal struct { + FullBodyThreshold float64 `json:"fullBodyThreshold"` +} + +type SignalConfig struct { + Weight float64 `json:"weight"` + BollingerBandTrendSignal *BollingerBandTrendSignal `json:"bollingerBandTrend,omitempty"` + OrderBookBestPriceSignal *OrderBookBestPriceVolumeSignal `json:"orderBookBestPrice,omitempty"` + KLineShapeSignal *KLineShapeSignal `json:"klineShape,omitempty"` +} + func init() { bbgo.RegisterStrategy(ID, &Strategy{}) } @@ -50,6 +85,8 @@ type Strategy struct { HedgeInterval types.Duration `json:"hedgeInterval"` OrderCancelWaitTime types.Duration `json:"orderCancelWaitTime"` + SignalConfigList []SignalConfig `json:"signals"` + Margin fixedpoint.Value `json:"margin"` BidMargin fixedpoint.Value `json:"bidMargin"` AskMargin fixedpoint.Value `json:"askMargin"` @@ -138,6 +175,8 @@ type Strategy struct { circuitBreakerAlertLimiter *rate.Limiter logger logrus.FieldLogger + + metricsLabels prometheus.Labels } func (s *Strategy) ID() string { @@ -192,18 +231,16 @@ func (s *Strategy) Initialize() error { "strategy": ID, "strategy_id": s.InstanceID(), }) + + s.metricsLabels = prometheus.Labels{ + "strategy_type": ID, + "strategy_id": s.InstanceID(), + "exchange": s.MakerExchange, + "symbol": s.Symbol, + } return nil } -type Quote struct { - BestBidPrice, BestAskPrice fixedpoint.Value - - BidMargin, AskMargin fixedpoint.Value - - // BidLayerPips is the price pips between each layer - BidLayerPips, AskLayerPips fixedpoint.Value -} - // getBollingerTrend returns -1 when the price is in the downtrend, 1 when the price is in the uptrend, 0 when the price is in the band func (s *Strategy) getBollingerTrend(quote *Quote) int { // when bid price is lower than the down band, then it's in the downtrend @@ -211,12 +248,6 @@ func (s *Strategy) getBollingerTrend(quote *Quote) int { lastDownBand := fixedpoint.NewFromFloat(s.boll.DownBand.Last(0)) lastUpBand := fixedpoint.NewFromFloat(s.boll.UpBand.Last(0)) - s.logger.Infof("bollinger band: up/down = %f/%f, bid/ask = %f/%f", - lastUpBand.Float64(), - lastDownBand.Float64(), - quote.BestBidPrice.Float64(), - quote.BestAskPrice.Float64()) - if quote.BestAskPrice.Compare(lastDownBand) < 0 { return -1 } else if quote.BestBidPrice.Compare(lastUpBand) > 0 { @@ -282,6 +313,43 @@ func (s *Strategy) applyBollingerMargin( return nil } +func (s *Strategy) calculateSignal(ctx context.Context) (float64, error) { + sum := 0.0 + voters := 0.0 + for _, signal := range s.SignalConfigList { + if signal.OrderBookBestPriceSignal != nil { + sig, err := signal.OrderBookBestPriceSignal.CalculateSignal(ctx) + if err != nil { + return 0, err + } + + if signal.Weight > 0.0 { + sum += sig * signal.Weight + voters += signal.Weight + } else { + sum += sig + voters++ + } + + } else if signal.BollingerBandTrendSignal != nil { + sig, err := signal.BollingerBandTrendSignal.CalculateSignal(ctx) + if err != nil { + return 0, err + } + + if signal.Weight > 0.0 { + sum += sig * signal.Weight + voters += signal.Weight + } else { + sum += sig + voters++ + } + } + } + + return sum / voters, nil +} + func (s *Strategy) updateQuote(ctx context.Context) { if err := s.activeMakerOrders.GracefulCancel(ctx, s.makerSession.Exchange); err != nil { s.logger.Warnf("there are some %s orders not canceled, skipping placing maker orders", s.Symbol) @@ -293,6 +361,15 @@ func (s *Strategy) updateQuote(ctx context.Context) { return } + signal, err := s.calculateSignal(ctx) + if err != nil { + return + } + + s.logger.Infof("Final signal: %f", signal) + + finalSignalMetrics.With(s.metricsLabels).Set(signal) + if s.CircuitBreaker != nil { now := time.Now() if reason, halted := s.CircuitBreaker.IsHalted(now); halted { @@ -500,13 +577,6 @@ func (s *Strategy) updateQuote(ctx context.Context) { } } - labels := prometheus.Labels{ - "strategy_type": ID, - "strategy_id": s.InstanceID(), - "exchange": s.MakerExchange, - "symbol": s.Symbol, - } - bidExposureInUsd := fixedpoint.Zero askExposureInUsd := fixedpoint.Zero bidPrice := quote.BestBidPrice @@ -520,8 +590,8 @@ func (s *Strategy) updateQuote(ctx context.Context) { return } - bidMarginMetrics.With(labels).Set(quote.BidMargin.Float64()) - askMarginMetrics.With(labels).Set(quote.AskMargin.Float64()) + bidMarginMetrics.With(s.metricsLabels).Set(quote.BidMargin.Float64()) + askMarginMetrics.With(s.metricsLabels).Set(quote.AskMargin.Float64()) for i := 0; i < s.NumLayers; i++ { // for maker bid orders @@ -566,7 +636,7 @@ func (s *Strategy) updateQuote(ctx context.Context) { if i == 0 { s.logger.Infof("maker best bid price %f", bidPrice.Float64()) - makerBestBidPriceMetrics.With(labels).Set(bidPrice.Float64()) + makerBestBidPriceMetrics.With(s.metricsLabels).Set(bidPrice.Float64()) } if makerQuota.QuoteAsset.Lock(bidQuantity.Mul(bidPrice)) && hedgeQuota.BaseAsset.Lock(bidQuantity) { @@ -634,7 +704,7 @@ func (s *Strategy) updateQuote(ctx context.Context) { if i == 0 { s.logger.Infof("maker best ask price %f", askPrice.Float64()) - makerBestAskPriceMetrics.With(labels).Set(askPrice.Float64()) + makerBestAskPriceMetrics.With(s.metricsLabels).Set(askPrice.Float64()) } if makerQuota.BaseAsset.Lock(askQuantity) && hedgeQuota.QuoteAsset.Lock(askQuantity.Mul(askPrice)) { @@ -687,8 +757,8 @@ func (s *Strategy) updateQuote(ctx context.Context) { log.WithError(err).Errorf("unable to place maker orders: %+v", formattedOrders) } - openOrderBidExposureInUsdMetrics.With(labels).Set(bidExposureInUsd.Float64()) - openOrderAskExposureInUsdMetrics.With(labels).Set(askExposureInUsd.Float64()) + openOrderBidExposureInUsdMetrics.With(s.metricsLabels).Set(bidExposureInUsd.Float64()) + openOrderAskExposureInUsdMetrics.With(s.metricsLabels).Set(askExposureInUsd.Float64()) _ = errIdx _ = createdOrders @@ -1039,7 +1109,6 @@ func (s *Strategy) hedgeWorker(ctx context.Context) { func (s *Strategy) CrossRun( ctx context.Context, orderExecutionRouter bbgo.OrderExecutionRouter, sessions map[string]*bbgo.ExchangeSession, ) error { - instanceID := s.InstanceID() // configure sessions @@ -1125,6 +1194,14 @@ func (s *Strategy) CrossRun( }) } + s.sourceSession.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, types.Interval1m, func(k types.KLine) { + s.priceSolver.Update(k.Symbol, k.Close) + feeToken := s.sourceSession.Exchange.PlatformFeeCurrency() + if feePrice, ok := s.priceSolver.ResolvePrice(feeToken, "USDT"); ok { + s.Position.SetFeeAverageCost(feeToken, feePrice) + } + })) + if s.ProfitFixerConfig != nil { bbgo.Notify("Fixing %s profitStats and position...", s.Symbol) @@ -1169,6 +1246,25 @@ func (s *Strategy) CrossRun( s.book = types.NewStreamBook(s.Symbol, s.sourceSession.ExchangeName) s.book.BindStream(s.sourceSession.MarketDataStream) + for _, signalConfig := range s.SignalConfigList { + var sigAny any + switch { + case signalConfig.OrderBookBestPriceSignal != nil: + sig := signalConfig.OrderBookBestPriceSignal + sig.book = s.book + sigAny = sig + + case signalConfig.BollingerBandTrendSignal != nil: + + } + + if sigAny != nil { + if binder, ok := sigAny.(SessionBinder); ok { + binder.Bind(ctx, s.sourceSession, s.Symbol) + } + } + } + s.activeMakerOrders = bbgo.NewActiveOrderBook(s.Symbol) s.activeMakerOrders.BindStream(s.makerSession.UserDataStream)