diff --git a/config/scmaker.yaml b/config/scmaker.yaml new file mode 100644 index 000000000..144de5c55 --- /dev/null +++ b/config/scmaker.yaml @@ -0,0 +1,50 @@ +sessions: + binance: + exchange: max + envVarPrefix: max + + +exchangeStrategies: +- on: max + scmaker: + symbol: USDCUSDT + + ## adjustmentUpdateInterval is the interval for adjusting position + adjustmentUpdateInterval: 1m + + ## liquidityUpdateInterval is the interval for updating liquidity orders + liquidityUpdateInterval: 1h + + midPriceEMA: + interval: 1h + window: 99 + + ## priceRangeBollinger is used for the liquidity price range + priceRangeBollinger: + interval: 1h + window: 10 + k: 1.0 + + numOfLiquidityLayers: 10 + + liquidityLayerTick: 0.01 + + strengthInterval: 1m + + liquidityScale: + exp: + domain: [0, 10] + range: [100, 500] + +backtest: + sessions: + - max + startTime: "2023-05-01" + endTime: "2023-06-01" + symbols: + - USDCUSDT + account: + max: + balances: + USDC: 5000 + USDT: 5000 diff --git a/pkg/cmd/strategy/builtin.go b/pkg/cmd/strategy/builtin.go index 0a76cfb4e..995f9f5b8 100644 --- a/pkg/cmd/strategy/builtin.go +++ b/pkg/cmd/strategy/builtin.go @@ -29,6 +29,7 @@ import ( _ "github.com/c9s/bbgo/pkg/strategy/rebalance" _ "github.com/c9s/bbgo/pkg/strategy/rsmaker" _ "github.com/c9s/bbgo/pkg/strategy/schedule" + _ "github.com/c9s/bbgo/pkg/strategy/scmaker" _ "github.com/c9s/bbgo/pkg/strategy/skeleton" _ "github.com/c9s/bbgo/pkg/strategy/supertrend" _ "github.com/c9s/bbgo/pkg/strategy/support" diff --git a/pkg/strategy/scmaker/intensity.go b/pkg/strategy/scmaker/intensity.go new file mode 100644 index 000000000..b4f3cb383 --- /dev/null +++ b/pkg/strategy/scmaker/intensity.go @@ -0,0 +1,44 @@ +package scmaker + +import ( + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/indicator" + "github.com/c9s/bbgo/pkg/types" +) + +type IntensityStream struct { + *indicator.Float64Series + + Buy, Sell *indicator.RMAStream + window int +} + +func Intensity(source indicator.KLineSubscription, window int) *IntensityStream { + s := &IntensityStream{ + Float64Series: indicator.NewFloat64Series(), + window: window, + + Buy: indicator.RMA2(indicator.NewFloat64Series(), window, false), + Sell: indicator.RMA2(indicator.NewFloat64Series(), window, false), + } + + threshold := fixedpoint.NewFromFloat(100.0) + source.AddSubscriber(func(k types.KLine) { + volume := k.Volume.Float64() + + // ignore zero volume events or <= 10usd events + if volume == 0.0 || k.Close.Mul(k.Volume).Compare(threshold) <= 0 { + return + } + + c := k.Close.Compare(k.Open) + if c > 0 { + s.Buy.PushAndEmit(volume) + } else if c < 0 { + s.Sell.PushAndEmit(volume) + } + s.Float64Series.PushAndEmit(k.High.Sub(k.Low).Float64()) + }) + + return s +} diff --git a/pkg/strategy/scmaker/strategy.go b/pkg/strategy/scmaker/strategy.go new file mode 100644 index 000000000..ade173865 --- /dev/null +++ b/pkg/strategy/scmaker/strategy.go @@ -0,0 +1,326 @@ +package scmaker + +import ( + "context" + "fmt" + "math" + + log "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/indicator" + "github.com/c9s/bbgo/pkg/types" +) + +const ID = "scmaker" + +var ten = fixedpoint.NewFromInt(10) + +type BollingerConfig struct { + Interval types.Interval `json:"interval"` + Window int `json:"window"` + K float64 `json:"k"` +} + +func init() { + bbgo.RegisterStrategy(ID, &Strategy{}) +} + +// scmaker is a stable coin market maker +type Strategy struct { + Environment *bbgo.Environment + Market types.Market + + Symbol string `json:"symbol"` + + NumOfLiquidityLayers int `json:"numOfLiquidityLayers"` + + LiquidityUpdateInterval types.Interval `json:"liquidityUpdateInterval"` + PriceRangeBollinger *BollingerConfig `json:"priceRangeBollinger"` + StrengthInterval types.Interval `json:"strengthInterval"` + + AdjustmentUpdateInterval types.Interval `json:"adjustmentUpdateInterval"` + + MidPriceEMA *types.IntervalWindow `json:"midPriceEMA"` + LiquiditySlideRule *bbgo.SlideRule `json:"liquidityScale"` + + Position *types.Position `json:"position,omitempty" persistence:"position"` + ProfitStats *types.ProfitStats `json:"profitStats,omitempty" persistence:"profit_stats"` + + session *bbgo.ExchangeSession + orderExecutor *bbgo.GeneralOrderExecutor + liquidityOrderBook, adjustmentOrderBook *bbgo.ActiveOrderBook + book *types.StreamOrderBook + + liquidityScale bbgo.Scale + + // indicators + ewma *indicator.EWMAStream + boll *indicator.BOLLStream + intensity *IntensityStream +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { + session.Subscribe(types.BookChannel, s.Symbol, types.SubscribeOptions{}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.AdjustmentUpdateInterval}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.LiquidityUpdateInterval}) + + if s.MidPriceEMA != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.MidPriceEMA.Interval}) + } +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + instanceID := s.InstanceID() + + s.session = session + s.book = types.NewStreamBook(s.Symbol) + s.book.BindStream(session.UserDataStream) + + s.liquidityOrderBook = bbgo.NewActiveOrderBook(s.Symbol) + s.adjustmentOrderBook = bbgo.NewActiveOrderBook(s.Symbol) + + // If position is nil, we need to allocate a new position for calculation + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + // Always update the position fields + s.Position.Strategy = ID + s.Position.StrategyInstanceID = instanceID + + if s.session.MakerFeeRate.Sign() > 0 || s.session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(s.session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: s.session.MakerFeeRate, + TakerFeeRate: s.session.TakerFeeRate, + }) + } + + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + + scale, err := s.LiquiditySlideRule.Scale() + if err != nil { + return err + } + + if err := scale.Solve(); err != nil { + return err + } + + s.liquidityScale = scale + + s.orderExecutor = bbgo.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.Bind() + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + bbgo.Sync(ctx, s) + }) + + s.initializeMidPriceEMA(session) + s.initializePriceRangeBollinger(session) + s.initializeIntensityIndicator(session) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.AdjustmentUpdateInterval, func(k types.KLine) { + s.placeAdjustmentOrders(ctx) + })) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.LiquidityUpdateInterval, func(k types.KLine) { + s.placeLiquidityOrders(ctx) + })) + + return nil +} + +func (s *Strategy) initializeMidPriceEMA(session *bbgo.ExchangeSession) { + kLines := indicator.KLines(session.MarketDataStream, s.Symbol, s.MidPriceEMA.Interval) + s.ewma = indicator.EWMA2(indicator.ClosePrices(kLines), s.MidPriceEMA.Window) +} + +func (s *Strategy) initializeIntensityIndicator(session *bbgo.ExchangeSession) { + kLines := indicator.KLines(session.MarketDataStream, s.Symbol, s.StrengthInterval) + s.intensity = Intensity(kLines, 10) +} + +func (s *Strategy) initializePriceRangeBollinger(session *bbgo.ExchangeSession) { + kLines := indicator.KLines(session.MarketDataStream, s.Symbol, s.PriceRangeBollinger.Interval) + closePrices := indicator.ClosePrices(kLines) + s.boll = indicator.BOLL2(closePrices, s.PriceRangeBollinger.Window, s.PriceRangeBollinger.K) +} + +func (s *Strategy) placeAdjustmentOrders(ctx context.Context) { + if s.Position.IsDust() { + return + } +} + +func (s *Strategy) placeLiquidityOrders(ctx context.Context) { + _ = s.liquidityOrderBook.GracefulCancel(ctx, s.session.Exchange) + + ticker, err := s.session.Exchange.QueryTicker(ctx, s.Symbol) + if logErr(err, "unable to query ticker") { + return + } + + baseBal, _ := s.session.Account.Balance(s.Market.BaseCurrency) + quoteBal, _ := s.session.Account.Balance(s.Market.QuoteCurrency) + + spread := ticker.Sell.Sub(ticker.Buy) + _ = spread + + midPriceEMA := s.ewma.Last(0) + midPrice := fixedpoint.NewFromFloat(midPriceEMA) + + makerQuota := &bbgo.QuotaTransaction{} + makerQuota.QuoteAsset.Add(quoteBal.Available) + makerQuota.BaseAsset.Add(baseBal.Available) + + bandWidth := s.boll.Last(0) + _ = bandWidth + + log.Infof("mid price ema: %f boll band width: %f", midPriceEMA, bandWidth) + + var liqOrders []types.SubmitOrder + for i := 0; i <= s.NumOfLiquidityLayers; i++ { + fi := fixedpoint.NewFromInt(int64(i)) + quantity := fixedpoint.NewFromFloat(s.liquidityScale.Call(float64(i))) + bidPrice := midPrice.Sub(s.Market.TickSize.Mul(fi)) + askPrice := midPrice.Add(s.Market.TickSize.Mul(fi)) + if i == 0 { + bidPrice = ticker.Buy + askPrice = ticker.Sell + } + + log.Infof("layer #%d %f/%f = %f", i, askPrice.Float64(), bidPrice.Float64(), quantity.Float64()) + + placeBuy := true + placeSell := true + averageCost := s.Position.AverageCost + // when long position, do not place sell orders below the average cost + if !s.Position.IsDust() { + if s.Position.IsLong() && askPrice.Compare(averageCost) < 0 { + placeSell = false + } + + if s.Position.IsShort() && bidPrice.Compare(averageCost) > 0 { + placeBuy = false + } + } + + quoteQuantity := quantity.Mul(bidPrice) + + if !makerQuota.QuoteAsset.Lock(quoteQuantity) { + placeBuy = false + } + + if !makerQuota.BaseAsset.Lock(quantity) { + placeSell = false + } + + if placeBuy { + liqOrders = append(liqOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimitMaker, + Quantity: quantity, + Price: bidPrice, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }) + } + + if placeSell { + liqOrders = append(liqOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimitMaker, + Quantity: quantity, + Price: askPrice, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }) + } + } + + _, err = s.orderExecutor.SubmitOrders(ctx, liqOrders...) + logErr(err, "unable to place liquidity orders") +} + +func (s *Strategy) generateOrders(symbol string, side types.SideType, price, priceTick, baseQuantity fixedpoint.Value, numOrders int) (orders []types.SubmitOrder) { + var expBase = fixedpoint.Zero + + switch side { + case types.SideTypeBuy: + if priceTick.Sign() > 0 { + priceTick = priceTick.Neg() + } + + case types.SideTypeSell: + if priceTick.Sign() < 0 { + priceTick = priceTick.Neg() + } + } + + decdigits := priceTick.Abs().NumIntDigits() + step := priceTick.Abs().MulExp(-decdigits + 1) + + for i := 0; i < numOrders; i++ { + quantityExp := fixedpoint.NewFromFloat(math.Exp(expBase.Float64())) + volume := baseQuantity.Mul(quantityExp) + amount := volume.Mul(price) + // skip order less than 10usd + if amount.Compare(ten) < 0 { + log.Warnf("amount too small (< 10usd). price=%s volume=%s amount=%s", + price.String(), volume.String(), amount.String()) + continue + } + + orders = append(orders, types.SubmitOrder{ + Symbol: symbol, + Side: side, + Type: types.OrderTypeLimit, + Price: price, + Quantity: volume, + }) + + log.Infof("%s order: %s @ %s", side, volume.String(), price.String()) + + if len(orders) >= numOrders { + break + } + + price = price.Add(priceTick) + expBase = expBase.Add(step) + } + + return orders +} + +func logErr(err error, msgAndArgs ...interface{}) bool { + if err == nil { + return false + } + + if len(msgAndArgs) == 0 { + log.WithError(err).Error(err.Error()) + } else if len(msgAndArgs) == 1 { + msg := msgAndArgs[0].(string) + log.WithError(err).Error(msg) + } else if len(msgAndArgs) > 1 { + msg := msgAndArgs[0].(string) + log.WithError(err).Errorf(msg, msgAndArgs[1:]...) + } + + return true +} diff --git a/pkg/types/position.go b/pkg/types/position.go index bd428fe43..6cee34e75 100644 --- a/pkg/types/position.go +++ b/pkg/types/position.go @@ -170,7 +170,12 @@ func (p *Position) NewMarketCloseOrder(percentage fixedpoint.Value) *SubmitOrder } } -func (p *Position) IsDust(price fixedpoint.Value) bool { +func (p *Position) IsDust(a ...fixedpoint.Value) bool { + price := p.AverageCost + if len(a) > 0 { + price = a[0] + } + base := p.Base.Abs() return p.Market.IsDustQuantity(base, price) }