diff --git a/pkg/strategy/xmaker/metrics.go b/pkg/strategy/xmaker/metrics.go index a05743d19..adedf59f6 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 aggregatedSignalMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "xmaker_aggregated_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, + aggregatedSignalMetrics, 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..f40cd1edc --- /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)) + + maxBandWidth := s.indicator.StdDev.Last(0) * s.MaxBandWidth + + signal := 0.0 + + // if the price is inside the band, do not vote + if closePrice.Compare(lastDownBand) > 0 && closePrice.Compare(lastUpBand) < 0 { + signal = 0.0 + } else 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("[BollingerBandTrendSignal] %f up/down = %f/%f, close price = %f", + signal, + lastUpBand.Float64(), + lastDownBand.Float64(), + closePrice.Float64()) + + bollingerBandSignalMetrics.WithLabelValues(s.symbol).Set(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..ac7288e51 --- /dev/null +++ b/pkg/strategy/xmaker/signal_book.go @@ -0,0 +1,68 @@ +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 + } + + // 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 bid.Volume.Compare(s.MinVolume) < 0 && ask.Volume.Compare(s.MinVolume) < 0 { + signal = 0.0 + } else 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() + } + + log.Infof("[OrderBookBestPriceVolumeSignal] %f bid/ask = %f/%f", signal, bid.Volume.Float64(), ask.Volume.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 67811e630..db4d29a5c 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -25,14 +25,47 @@ import ( var defaultMargin = fixedpoint.NewFromFloat(0.003) var two = fixedpoint.NewFromInt(2) -var lastPriceModifier = fixedpoint.NewFromFloat(1.001) - const priceUpdateTimeout = 30 * time.Second 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{}) } @@ -52,6 +85,10 @@ type Strategy struct { HedgeInterval types.Duration `json:"hedgeInterval"` OrderCancelWaitTime types.Duration `json:"orderCancelWaitTime"` + EnableSignalMargin bool `json:"enableSignalMargin"` + SignalConfigList []SignalConfig `json:"signals"` + SignalMarginScale *bbgo.SlideRule `json:"signalMarginScale,omitempty"` + Margin fixedpoint.Value `json:"margin"` BidMargin fixedpoint.Value `json:"bidMargin"` AskMargin fixedpoint.Value `json:"askMargin"` @@ -140,6 +177,8 @@ type Strategy struct { circuitBreakerAlertLimiter *rate.Limiter logger logrus.FieldLogger + + metricsLabels prometheus.Labels } func (s *Strategy) ID() string { @@ -194,18 +233,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 @@ -213,12 +250,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 { @@ -228,6 +259,43 @@ func (s *Strategy) getBollingerTrend(quote *Quote) int { } } +func (s *Strategy) applySignalMargin(ctx context.Context, quote *Quote) error { + signal, err := s.calculateSignal(ctx) + if err != nil { + return err + } + + s.logger.Infof("aggregated signal: %f", signal) + + scale, err := s.SignalMarginScale.Scale() + if err != nil { + return err + } + + margin := scale.Call(signal) + + s.logger.Infof("signal margin: %f", margin) + + marginFp := fixedpoint.NewFromFloat(margin) + if signal < 0.0 { + quote.BidMargin = quote.BidMargin.Add(marginFp) + if signal <= -2.0 { + // quote.BidMargin = fixedpoint.Zero + } + + s.logger.Infof("adjusted bid margin: %f", quote.BidMargin.Float64()) + } else if signal > 0.0 { + quote.AskMargin = quote.AskMargin.Add(marginFp) + if signal >= 2.0 { + // quote.AskMargin = fixedpoint.Zero + } + + s.logger.Infof("adjusted ask margin: %f", quote.AskMargin.Float64()) + } + + return nil +} + // applyBollingerMargin applies the bollinger band margin to the quote func (s *Strategy) applyBollingerMargin( quote *Quote, @@ -284,6 +352,51 @@ 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 sig == 0.0 { + continue + } + + 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 sig == 0.0 { + continue + } + + 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) @@ -295,6 +408,14 @@ func (s *Strategy) updateQuote(ctx context.Context) { return } + signal, err := s.calculateSignal(ctx) + if err != nil { + return + } + + s.logger.Infof("aggregated signal: %f", signal) + aggregatedSignalMetrics.With(s.metricsLabels).Set(signal) + if s.CircuitBreaker != nil { now := time.Now() if reason, halted := s.CircuitBreaker.IsHalted(now); halted { @@ -496,19 +617,17 @@ func (s *Strategy) updateQuote(ctx context.Context) { AskLayerPips: s.Pips, } - if s.EnableBollBandMargin { + if s.EnableSignalMargin { + if err := s.applySignalMargin(ctx, quote); err != nil { + s.logger.WithError(err).Errorf("unable to apply signal margin") + } + + } else if s.EnableBollBandMargin { if err := s.applyBollingerMargin(quote); err != nil { log.WithError(err).Errorf("unable to apply bollinger margin") } } - 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 @@ -522,8 +641,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 @@ -568,7 +687,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) { @@ -636,7 +755,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)) { @@ -689,8 +808,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 @@ -1041,7 +1160,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 @@ -1127,6 +1245,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) @@ -1171,6 +1297,29 @@ func (s *Strategy) CrossRun( s.book = types.NewStreamBook(s.Symbol, s.sourceSession.ExchangeName) s.book.BindStream(s.sourceSession.MarketDataStream) + if s.EnableSignalMargin { + scale, err := s.SignalMarginScale.Scale() + if err != nil { + return err + } + if solveErr := scale.Solve(); solveErr != nil { + return solveErr + } + } + + for _, signalConfig := range s.SignalConfigList { + if signalConfig.OrderBookBestPriceSignal != nil { + signalConfig.OrderBookBestPriceSignal.book = s.book + if err := signalConfig.OrderBookBestPriceSignal.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 + } + } + } + s.activeMakerOrders = bbgo.NewActiveOrderBook(s.Symbol) s.activeMakerOrders.BindStream(s.makerSession.UserDataStream)