diff --git a/config/liquiditymaker.yaml b/config/liquiditymaker.yaml new file mode 100644 index 000000000..85288d5b9 --- /dev/null +++ b/config/liquiditymaker.yaml @@ -0,0 +1,54 @@ +sessions: + max: + exchange: max + envVarPrefix: max + makerFeeRate: 0% + takerFeeRate: 0.025% + +#services: +# googleSpreadSheet: +# jsonTokenFile: ".credentials/google-cloud/service-account-json-token.json" +# spreadSheetId: "YOUR_SPREADSHEET_ID" + +exchangeStrategies: +- on: max + liquiditymaker: + symbol: &symbol USDTTWD + + ## adjustmentUpdateInterval is the interval for adjusting position + adjustmentUpdateInterval: 1m + + ## liquidityUpdateInterval is the interval for updating liquidity orders + liquidityUpdateInterval: 1h + + numOfLiquidityLayers: 30 + askLiquidityAmount: 20_000.0 + bidLiquidityAmount: 20_000.0 + liquidityPriceRange: 2% + useLastTradePrice: true + spread: 1.1% + + liquidityScale: + exp: + domain: [1, 30] + range: [1, 4] + + ## maxExposure controls how much balance should be used for placing the maker orders + maxExposure: 200_000 + minProfit: 0.01% + + +backtest: + sessions: + - max + startTime: "2023-05-20" + endTime: "2023-06-01" + symbols: + - *symbol + account: + max: + makerFeeRate: 0.0% + takerFeeRate: 0.025% + balances: + USDT: 5000 + TWD: 150_000 diff --git a/pkg/cmd/strategy/builtin.go b/pkg/cmd/strategy/builtin.go index d868e926a..867c72dc2 100644 --- a/pkg/cmd/strategy/builtin.go +++ b/pkg/cmd/strategy/builtin.go @@ -25,6 +25,7 @@ import ( _ "github.com/c9s/bbgo/pkg/strategy/irr" _ "github.com/c9s/bbgo/pkg/strategy/kline" _ "github.com/c9s/bbgo/pkg/strategy/linregmaker" + _ "github.com/c9s/bbgo/pkg/strategy/liquiditymaker" _ "github.com/c9s/bbgo/pkg/strategy/marketcap" _ "github.com/c9s/bbgo/pkg/strategy/pivotshort" _ "github.com/c9s/bbgo/pkg/strategy/pricealert" diff --git a/pkg/strategy/liquiditymaker/generator.go b/pkg/strategy/liquiditymaker/generator.go new file mode 100644 index 000000000..d21d79b83 --- /dev/null +++ b/pkg/strategy/liquiditymaker/generator.go @@ -0,0 +1,96 @@ +package liquiditymaker + +import ( + log "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +// input: liquidityOrderGenerator( +// +// totalLiquidityAmount, +// startPrice, +// endPrice, +// numLayers, +// quantityScale) +// +// when side == sell +// +// priceAsk1 * scale(1) * f = amount1 +// priceAsk2 * scale(2) * f = amount2 +// priceAsk3 * scale(3) * f = amount3 +// +// totalLiquidityAmount = priceAsk1 * scale(1) * f + priceAsk2 * scale(2) * f + priceAsk3 * scale(3) * f + .... +// totalLiquidityAmount = f * (priceAsk1 * scale(1) + priceAsk2 * scale(2) + priceAsk3 * scale(3) + ....) +// f = totalLiquidityAmount / (priceAsk1 * scale(1) + priceAsk2 * scale(2) + priceAsk3 * scale(3) + ....) +// +// when side == buy +// +// priceBid1 * scale(1) * f = amount1 +type LiquidityOrderGenerator struct { + Symbol string + Market types.Market + + logger log.FieldLogger +} + +func (g *LiquidityOrderGenerator) Generate( + side types.SideType, totalAmount, startPrice, endPrice fixedpoint.Value, numLayers int, scale bbgo.Scale, +) (orders []types.SubmitOrder) { + + if g.logger == nil { + logger := log.New() + logger.SetLevel(log.ErrorLevel) + g.logger = logger + } + + layerSpread := endPrice.Sub(startPrice).Div(fixedpoint.NewFromInt(int64(numLayers - 1))) + switch side { + case types.SideTypeSell: + if layerSpread.Compare(g.Market.TickSize) < 0 { + layerSpread = g.Market.TickSize + } + + case types.SideTypeBuy: + if layerSpread.Compare(g.Market.TickSize.Neg()) > 0 { + layerSpread = g.Market.TickSize.Neg() + } + } + + quantityBase := 0.0 + var layerPrices []fixedpoint.Value + var layerScales []float64 + for i := 0; i < numLayers; i++ { + fi := fixedpoint.NewFromInt(int64(i)) + layerPrice := g.Market.TruncatePrice(startPrice.Add(layerSpread.Mul(fi))) + layerPrices = append(layerPrices, layerPrice) + + layerScale := scale.Call(float64(i + 1)) + layerScales = append(layerScales, layerScale) + + quantityBase += layerPrice.Float64() * layerScale + } + + factor := totalAmount.Float64() / quantityBase + + g.logger.Infof("liquidity amount base: %f, factor: %f", quantityBase, factor) + + for i := 0; i < numLayers; i++ { + price := layerPrices[i] + s := layerScales[i] + + quantity := factor * s + orders = append(orders, types.SubmitOrder{ + Symbol: g.Symbol, + Price: price, + Type: types.OrderTypeLimitMaker, + Quantity: g.Market.TruncateQuantity(fixedpoint.NewFromFloat(quantity)), + Side: side, + Market: g.Market, + }) + } + + return orders +} diff --git a/pkg/strategy/liquiditymaker/generator_test.go b/pkg/strategy/liquiditymaker/generator_test.go new file mode 100644 index 000000000..995f33bd7 --- /dev/null +++ b/pkg/strategy/liquiditymaker/generator_test.go @@ -0,0 +1,114 @@ +//go:build !dnum + +package liquiditymaker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + . "github.com/c9s/bbgo/pkg/testing/testhelper" + "github.com/c9s/bbgo/pkg/types" +) + +func newTestMarket() types.Market { + return types.Market{ + BaseCurrency: "XML", + QuoteCurrency: "USDT", + TickSize: Number(0.0001), + StepSize: Number(0.01), + PricePrecision: 4, + VolumePrecision: 8, + MinNotional: Number(8.0), + MinQuantity: Number(40.0), + } +} + +func TestLiquidityOrderGenerator(t *testing.T) { + g := &LiquidityOrderGenerator{ + Symbol: "XMLUSDT", + Market: newTestMarket(), + } + + scale := &bbgo.ExponentialScale{ + Domain: [2]float64{1.0, 30.0}, + Range: [2]float64{1.0, 4.0}, + } + + err := scale.Solve() + assert.NoError(t, err) + assert.InDelta(t, 1.0, scale.Call(1.0), 0.00001) + assert.InDelta(t, 4.0, scale.Call(30.0), 0.00001) + + totalAmount := Number(20_000.0) + + t.Run("ask orders", func(t *testing.T) { + orders := g.Generate(types.SideTypeSell, totalAmount, Number(2.0), Number(2.04), 30, scale) + assert.Len(t, orders, 30) + + totalQuoteQuantity := fixedpoint.NewFromInt(0) + for _, o := range orders { + totalQuoteQuantity = totalQuoteQuantity.Add(o.Quantity.Mul(o.Price)) + } + assert.InDelta(t, totalAmount.Float64(), totalQuoteQuantity.Float64(), 1.0) + + AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{ + {Side: types.SideTypeSell, Price: Number("2.0000"), Quantity: Number("151.34")}, + {Side: types.SideTypeSell, Price: Number("2.0013"), Quantity: Number("158.75")}, + {Side: types.SideTypeSell, Price: Number("2.0027"), Quantity: Number("166.52")}, + {Side: types.SideTypeSell, Price: Number("2.0041"), Quantity: Number("174.67")}, + {Side: types.SideTypeSell, Price: Number("2.0055"), Quantity: Number("183.23")}, + {Side: types.SideTypeSell, Price: Number("2.0068"), Quantity: Number("192.20")}, + {Side: types.SideTypeSell, Price: Number("2.0082"), Quantity: Number("201.61")}, + {Side: types.SideTypeSell, Price: Number("2.0096"), Quantity: Number("211.48")}, + {Side: types.SideTypeSell, Price: Number("2.0110"), Quantity: Number("221.84")}, + {Side: types.SideTypeSell, Price: Number("2.0124"), Quantity: Number("232.70")}, + {Side: types.SideTypeSell, Price: Number("2.0137"), Quantity: Number("244.09")}, + {Side: types.SideTypeSell, Price: Number("2.0151"), Quantity: Number("256.04")}, + {Side: types.SideTypeSell, Price: Number("2.0165"), Quantity: Number("268.58")}, + {Side: types.SideTypeSell, Price: Number("2.0179"), Quantity: Number("281.73")}, + {Side: types.SideTypeSell, Price: Number("2.0193"), Quantity: Number("295.53")}, + }, orders[0:15]) + + AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{ + {Side: types.SideTypeSell, Price: Number("2.0386"), Quantity: Number("577.10")}, + {Side: types.SideTypeSell, Price: Number("2.0399"), Quantity: Number("605.36")}, + }, orders[28:30]) + }) + + t.Run("bid orders", func(t *testing.T) { + orders := g.Generate(types.SideTypeBuy, totalAmount, Number(2.0), Number(1.96), 30, scale) + assert.Len(t, orders, 30) + + totalQuoteQuantity := fixedpoint.NewFromInt(0) + for _, o := range orders { + totalQuoteQuantity = totalQuoteQuantity.Add(o.Quantity.Mul(o.Price)) + } + assert.InDelta(t, totalAmount.Float64(), totalQuoteQuantity.Float64(), 1.0) + + AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{ + {Side: types.SideTypeBuy, Price: Number("2.0000"), Quantity: Number("155.13")}, + {Side: types.SideTypeBuy, Price: Number("1.9986"), Quantity: Number("162.73")}, + {Side: types.SideTypeBuy, Price: Number("1.9972"), Quantity: Number("170.70")}, + {Side: types.SideTypeBuy, Price: Number("1.9958"), Quantity: Number("179.05")}, + {Side: types.SideTypeBuy, Price: Number("1.9944"), Quantity: Number("187.82")}, + {Side: types.SideTypeBuy, Price: Number("1.9931"), Quantity: Number("197.02")}, + {Side: types.SideTypeBuy, Price: Number("1.9917"), Quantity: Number("206.67")}, + {Side: types.SideTypeBuy, Price: Number("1.9903"), Quantity: Number("216.79")}, + {Side: types.SideTypeBuy, Price: Number("1.9889"), Quantity: Number("227.40")}, + {Side: types.SideTypeBuy, Price: Number("1.9875"), Quantity: Number("238.54")}, + {Side: types.SideTypeBuy, Price: Number("1.9862"), Quantity: Number("250.22")}, + {Side: types.SideTypeBuy, Price: Number("1.9848"), Quantity: Number("262.47")}, + {Side: types.SideTypeBuy, Price: Number("1.9834"), Quantity: Number("275.32")}, + {Side: types.SideTypeBuy, Price: Number("1.9820"), Quantity: Number("288.80")}, + {Side: types.SideTypeBuy, Price: Number("1.9806"), Quantity: Number("302.94")}, + }, orders[0:15]) + + AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{ + {Side: types.SideTypeBuy, Price: Number("1.9613"), Quantity: Number("591.58")}, + {Side: types.SideTypeBuy, Price: Number("1.9600"), Quantity: Number("620.54")}, + }, orders[28:30]) + }) +} diff --git a/pkg/strategy/liquiditymaker/strategy.go b/pkg/strategy/liquiditymaker/strategy.go new file mode 100644 index 000000000..9d90e8fed --- /dev/null +++ b/pkg/strategy/liquiditymaker/strategy.go @@ -0,0 +1,378 @@ +package liquiditymaker + +import ( + "context" + "fmt" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + . "github.com/c9s/bbgo/pkg/indicator/v2" + "github.com/c9s/bbgo/pkg/strategy/common" + "github.com/c9s/bbgo/pkg/types" +) + +const ID = "liquiditymaker" + +type advancedOrderCancelApi interface { + CancelAllOrders(ctx context.Context) ([]types.Order, error) + CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error) +} + +func init() { + bbgo.RegisterStrategy(ID, &Strategy{}) +} + +// Strategy is the strategy struct of LiquidityMaker +// liquidity maker does not care about the current price, it tries to place liquidity orders (limit maker orders) +// around the current mid price +// liquidity maker's target: +// - place enough total liquidity amount on the order book, for example, 20k USDT value liquidity on both sell and buy +// - ensure the spread by placing the orders from the mid price (or the last trade price) +type Strategy struct { + *common.Strategy + + Environment *bbgo.Environment + Market types.Market + + Symbol string `json:"symbol"` + + LiquidityUpdateInterval types.Interval `json:"liquidityUpdateInterval"` + + AdjustmentUpdateInterval types.Interval `json:"adjustmentUpdateInterval"` + + NumOfLiquidityLayers int `json:"numOfLiquidityLayers"` + LiquiditySlideRule *bbgo.SlideRule `json:"liquidityScale"` + LiquidityPriceRange fixedpoint.Value `json:"liquidityPriceRange"` + AskLiquidityAmount fixedpoint.Value `json:"askLiquidityAmount"` + BidLiquidityAmount fixedpoint.Value `json:"bidLiquidityAmount"` + + UseLastTradePrice bool `json:"useLastTradePrice"` + Spread fixedpoint.Value `json:"spread"` + MaxPrice fixedpoint.Value `json:"maxPrice"` + MinPrice fixedpoint.Value `json:"minPrice"` + + MaxExposure fixedpoint.Value `json:"maxExposure"` + + MinProfit fixedpoint.Value `json:"minProfit"` + + liquidityOrderBook, adjustmentOrderBook *bbgo.ActiveOrderBook + book *types.StreamOrderBook + + liquidityScale bbgo.Scale + + orderGenerator *LiquidityOrderGenerator +} + +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}) +} + +func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + s.Strategy = &common.Strategy{} + s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) + + s.orderGenerator = &LiquidityOrderGenerator{ + Symbol: s.Symbol, + Market: s.Market, + } + + s.book = types.NewStreamBook(s.Symbol) + s.book.BindStream(session.MarketDataStream) + + s.liquidityOrderBook = bbgo.NewActiveOrderBook(s.Symbol) + s.liquidityOrderBook.BindStream(session.UserDataStream) + + s.adjustmentOrderBook = bbgo.NewActiveOrderBook(s.Symbol) + s.adjustmentOrderBook.BindStream(session.UserDataStream) + + scale, err := s.LiquiditySlideRule.Scale() + if err != nil { + return err + } + + if err := scale.Solve(); err != nil { + return err + } + + if cancelApi, ok := session.Exchange.(advancedOrderCancelApi); ok { + _, _ = cancelApi.CancelOrdersBySymbol(ctx, s.Symbol) + } + + s.liquidityScale = scale + + session.UserDataStream.OnStart(func() { + s.placeLiquidityOrders(ctx) + }) + + session.MarketDataStream.OnKLineClosed(func(k types.KLine) { + if k.Interval == s.AdjustmentUpdateInterval { + s.placeAdjustmentOrders(ctx) + } + + if k.Interval == s.LiquidityUpdateInterval { + s.placeLiquidityOrders(ctx) + } + }) + + bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + if err := s.liquidityOrderBook.GracefulCancel(ctx, s.Session.Exchange); err != nil { + logErr(err, "unable to cancel liquidity orders") + } + + if err := s.adjustmentOrderBook.GracefulCancel(ctx, s.Session.Exchange); err != nil { + logErr(err, "unable to cancel adjustment orders") + } + }) + + return nil +} + +func (s *Strategy) placeAdjustmentOrders(ctx context.Context) { + _ = s.adjustmentOrderBook.GracefulCancel(ctx, s.Session.Exchange) + + if s.Position.IsDust() { + return + } + + ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol) + if logErr(err, "unable to query ticker") { + return + } + + if _, err := s.Session.UpdateAccount(ctx); err != nil { + logErr(err, "unable to update account") + return + } + + baseBal, _ := s.Session.Account.Balance(s.Market.BaseCurrency) + quoteBal, _ := s.Session.Account.Balance(s.Market.QuoteCurrency) + + var adjOrders []types.SubmitOrder + + posSize := s.Position.Base.Abs() + tickSize := s.Market.TickSize + + if s.Position.IsShort() { + price := profitProtectedPrice(types.SideTypeBuy, s.Position.AverageCost, ticker.Sell.Add(tickSize.Neg()), s.Session.MakerFeeRate, s.MinProfit) + quoteQuantity := fixedpoint.Min(price.Mul(posSize), quoteBal.Available) + bidQuantity := quoteQuantity.Div(price) + + if s.Market.IsDustQuantity(bidQuantity, price) { + return + } + + adjOrders = append(adjOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimitMaker, + Side: types.SideTypeBuy, + Price: price, + Quantity: bidQuantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }) + } else if s.Position.IsLong() { + price := profitProtectedPrice(types.SideTypeSell, s.Position.AverageCost, ticker.Buy.Add(tickSize), s.Session.MakerFeeRate, s.MinProfit) + askQuantity := fixedpoint.Min(posSize, baseBal.Available) + + if s.Market.IsDustQuantity(askQuantity, price) { + return + } + + adjOrders = append(adjOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimitMaker, + Side: types.SideTypeSell, + Price: price, + Quantity: askQuantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }) + } + + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, adjOrders...) + if logErr(err, "unable to place liquidity orders") { + return + } + + s.adjustmentOrderBook.Add(createdOrders...) +} + +func (s *Strategy) placeLiquidityOrders(ctx context.Context) { + err := s.liquidityOrderBook.GracefulCancel(ctx, s.Session.Exchange) + if logErr(err, "unable to cancel orders") { + return + } + + ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol) + if logErr(err, "unable to query ticker") { + return + } + + if s.IsHalted(ticker.Time) { + log.Warn("circuitBreakRiskControl: trading halted") + return + } + + if _, err := s.Session.UpdateAccount(ctx); err != nil { + logErr(err, "unable to update account") + return + } + + baseBal, _ := s.Session.Account.Balance(s.Market.BaseCurrency) + quoteBal, _ := s.Session.Account.Balance(s.Market.QuoteCurrency) + + if ticker.Buy.IsZero() && ticker.Sell.IsZero() { + ticker.Sell = ticker.Last.Add(s.Market.TickSize) + ticker.Buy = ticker.Last.Sub(s.Market.TickSize) + } else if ticker.Buy.IsZero() { + ticker.Buy = ticker.Sell.Sub(s.Market.TickSize) + } else if ticker.Sell.IsZero() { + ticker.Sell = ticker.Buy.Add(s.Market.TickSize) + } + + log.Infof("ticker: %+v", ticker) + + lastTradedPrice := ticker.Last + midPrice := ticker.Sell.Add(ticker.Buy).Div(fixedpoint.Two) + currentSpread := ticker.Sell.Sub(ticker.Buy) + sideSpread := s.Spread.Div(fixedpoint.Two) + + if s.UseLastTradePrice { + midPrice = lastTradedPrice + } + + log.Infof("current spread: %f lastTradedPrice: %f midPrice: %f", currentSpread.Float64(), lastTradedPrice.Float64(), midPrice.Float64()) + + ask1Price := midPrice.Mul(fixedpoint.One.Add(sideSpread)) + bid1Price := midPrice.Mul(fixedpoint.One.Sub(sideSpread)) + + askLastPrice := midPrice.Mul(fixedpoint.One.Add(s.LiquidityPriceRange)) + bidLastPrice := midPrice.Mul(fixedpoint.One.Sub(s.LiquidityPriceRange)) + log.Infof("wanted side spread: %f askRange: %f ~ %f bidRange: %f ~ %f", + sideSpread.Float64(), + ask1Price.Float64(), askLastPrice.Float64(), + bid1Price.Float64(), bidLastPrice.Float64()) + + availableBase := baseBal.Available + availableQuote := quoteBal.Available + + log.Infof("balances before liq orders: %s, %s", + baseBal.String(), + quoteBal.String()) + + if !s.Position.IsDust() { + if s.Position.IsLong() { + availableBase = availableBase.Sub(s.Position.Base) + availableBase = s.Market.RoundDownQuantityByPrecision(availableBase) + } else if s.Position.IsShort() { + posSizeInQuote := s.Position.Base.Mul(ticker.Sell) + availableQuote = availableQuote.Sub(posSizeInQuote) + } + } + + bidOrders := s.orderGenerator.Generate(types.SideTypeBuy, + fixedpoint.Min(s.BidLiquidityAmount, quoteBal.Available), + bid1Price, + bidLastPrice, + s.NumOfLiquidityLayers, + s.liquidityScale) + + askOrders := s.orderGenerator.Generate(types.SideTypeSell, + s.AskLiquidityAmount, + ask1Price, + askLastPrice, + s.NumOfLiquidityLayers, + s.liquidityScale) + + askOrders = filterAskOrders(askOrders, baseBal.Available) + + orderForms := append(bidOrders, askOrders...) + + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, orderForms...) + if logErr(err, "unable to place liquidity orders") { + return + } + + s.liquidityOrderBook.Add(createdOrders...) + log.Infof("%d liq orders are placed successfully", len(orderForms)) + for _, o := range createdOrders { + log.Infof("liq order: %+v", o) + } +} + +func profitProtectedPrice( + side types.SideType, averageCost, price, feeRate, minProfit fixedpoint.Value, +) fixedpoint.Value { + switch side { + case types.SideTypeSell: + minProfitPrice := averageCost.Add( + averageCost.Mul(feeRate.Add(minProfit))) + return fixedpoint.Max(minProfitPrice, price) + + case types.SideTypeBuy: + minProfitPrice := averageCost.Sub( + averageCost.Mul(feeRate.Add(minProfit))) + return fixedpoint.Min(minProfitPrice, price) + + } + return price +} + +func filterAskOrders(askOrders []types.SubmitOrder, available fixedpoint.Value) (out []types.SubmitOrder) { + usedBase := fixedpoint.Zero + for _, askOrder := range askOrders { + if usedBase.Add(askOrder.Quantity).Compare(available) > 0 { + return out + } + + usedBase = usedBase.Add(askOrder.Quantity) + out = append(out, askOrder) + } + + return out +} + +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 +} + +func preloadKLines( + inc *KLineStream, session *bbgo.ExchangeSession, symbol string, interval types.Interval, +) { + if store, ok := session.MarketDataStore(symbol); ok { + if kLinesData, ok := store.KLinesOfInterval(interval); ok { + for _, k := range *kLinesData { + inc.EmitUpdate(k) + } + } + } +} diff --git a/pkg/strategy/scmaker/strategy.go b/pkg/strategy/scmaker/strategy.go index ecccefc8b..79e3f8956 100644 --- a/pkg/strategy/scmaker/strategy.go +++ b/pkg/strategy/scmaker/strategy.go @@ -5,22 +5,18 @@ import ( "fmt" "math" "sync" - "time" log "github.com/sirupsen/logrus" "github.com/c9s/bbgo/pkg/bbgo" "github.com/c9s/bbgo/pkg/fixedpoint" . "github.com/c9s/bbgo/pkg/indicator/v2" - "github.com/c9s/bbgo/pkg/risk/riskcontrol" "github.com/c9s/bbgo/pkg/strategy/common" "github.com/c9s/bbgo/pkg/types" ) const ID = "scmaker" -var ten = fixedpoint.NewFromInt(10) - type advancedOrderCancelApi interface { CancelAllOrders(ctx context.Context) ([]types.Order, error) CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error) @@ -62,12 +58,6 @@ type Strategy struct { MinProfit fixedpoint.Value `json:"minProfit"` - // risk related parameters - PositionHardLimit fixedpoint.Value `json:"positionHardLimit"` - MaxPositionQuantity fixedpoint.Value `json:"maxPositionQuantity"` - CircuitBreakLossThreshold fixedpoint.Value `json:"circuitBreakLossThreshold"` - CircuitBreakEMA types.IntervalWindow `json:"circuitBreakEMA"` - liquidityOrderBook, adjustmentOrderBook *bbgo.ActiveOrderBook book *types.StreamOrderBook @@ -77,9 +67,6 @@ type Strategy struct { ewma *EWMAStream boll *BOLLStream intensity *IntensityStream - - positionRiskControl *riskcontrol.PositionRiskControl - circuitBreakRiskControl *riskcontrol.CircuitBreakRiskControl } func (s *Strategy) ID() string { @@ -100,12 +87,12 @@ func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { } } -func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { +func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { s.Strategy = &common.Strategy{} s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) s.book = types.NewStreamBook(s.Symbol) - s.book.BindStream(session.UserDataStream) + s.book.BindStream(session.MarketDataStream) s.liquidityOrderBook = bbgo.NewActiveOrderBook(s.Symbol) s.liquidityOrderBook.BindStream(session.UserDataStream) @@ -113,21 +100,6 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.adjustmentOrderBook = bbgo.NewActiveOrderBook(s.Symbol) s.adjustmentOrderBook.BindStream(session.UserDataStream) - if !s.PositionHardLimit.IsZero() && !s.MaxPositionQuantity.IsZero() { - log.Infof("positionHardLimit and maxPositionQuantity are configured, setting up PositionRiskControl...") - s.positionRiskControl = riskcontrol.NewPositionRiskControl(s.OrderExecutor, s.PositionHardLimit, s.MaxPositionQuantity) - } - - if !s.CircuitBreakLossThreshold.IsZero() { - log.Infof("circuitBreakLossThreshold is configured, setting up CircuitBreakRiskControl...") - s.circuitBreakRiskControl = riskcontrol.NewCircuitBreakRiskControl( - s.Position, - session.Indicators(s.Symbol).EWMA(s.CircuitBreakEMA), - s.CircuitBreakLossThreshold, - s.ProfitStats, - 24*time.Hour) - } - scale, err := s.LiquiditySlideRule.Scale() if err != nil { return err @@ -174,7 +146,9 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se return nil } -func (s *Strategy) preloadKLines(inc *KLineStream, session *bbgo.ExchangeSession, symbol string, interval types.Interval) { +func (s *Strategy) preloadKLines( + inc *KLineStream, session *bbgo.ExchangeSession, symbol string, interval types.Interval, +) { if store, ok := session.MarketDataStore(symbol); ok { if kLinesData, ok := store.KLinesOfInterval(interval); ok { for _, k := range *kLinesData { @@ -282,7 +256,7 @@ func (s *Strategy) placeLiquidityOrders(ctx context.Context) { return } - if s.circuitBreakRiskControl != nil && s.circuitBreakRiskControl.IsHalted(ticker.Time) { + if s.IsHalted(ticker.Time) { log.Warn("circuitBreakRiskControl: trading halted") return } @@ -476,7 +450,9 @@ func (s *Strategy) placeLiquidityOrders(ctx context.Context) { log.Infof("%d liq orders are placed successfully", len(liqOrders)) } -func profitProtectedPrice(side types.SideType, averageCost, price, feeRate, minProfit fixedpoint.Value) fixedpoint.Value { +func profitProtectedPrice( + side types.SideType, averageCost, price, feeRate, minProfit fixedpoint.Value, +) fixedpoint.Value { switch side { case types.SideTypeSell: minProfitPrice := averageCost.Add( diff --git a/pkg/testing/testhelper/assert_priceside.go b/pkg/testing/testhelper/assert_priceside.go new file mode 100644 index 000000000..7c45cdd9d --- /dev/null +++ b/pkg/testing/testhelper/assert_priceside.go @@ -0,0 +1,58 @@ +package testhelper + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +type PriceSideAssert struct { + Price fixedpoint.Value + Side types.SideType +} + +// AssertOrdersPriceSide asserts the orders with the given price and side (slice) +func AssertOrdersPriceSide(t *testing.T, asserts []PriceSideAssert, orders []types.SubmitOrder) { + for i, a := range asserts { + assert.Equalf(t, a.Price, orders[i].Price, "order #%d price should be %f", i+1, a.Price.Float64()) + assert.Equalf(t, a.Side, orders[i].Side, "order at price %f should be %s", a.Price.Float64(), a.Side) + } +} + +type PriceSideQuantityAssert struct { + Price fixedpoint.Value + Side types.SideType + Quantity fixedpoint.Value +} + +// AssertOrdersPriceSide asserts the orders with the given price and side (slice) +func AssertOrdersPriceSideQuantity( + t *testing.T, asserts []PriceSideQuantityAssert, orders []types.SubmitOrder, +) { + assert.Equalf(t, len(orders), len(asserts), "expecting %d orders", len(asserts)) + + var assertPrices, orderPrices fixedpoint.Slice + var assertPricesFloat, orderPricesFloat []float64 + for _, a := range asserts { + assertPrices = append(assertPrices, a.Price) + assertPricesFloat = append(assertPricesFloat, a.Price.Float64()) + } + + for _, o := range orders { + orderPrices = append(orderPrices, o.Price) + orderPricesFloat = append(orderPricesFloat, o.Price.Float64()) + } + + if !assert.Equalf(t, assertPricesFloat, orderPricesFloat, "assert prices") { + return + } + + for i, a := range asserts { + assert.Equalf(t, a.Price.Float64(), orders[i].Price.Float64(), "order #%d price should be %f", i+1, a.Price.Float64()) + assert.Equalf(t, a.Quantity.Float64(), orders[i].Quantity.Float64(), "order #%d quantity should be %f", i+1, a.Quantity.Float64()) + assert.Equalf(t, a.Side, orders[i].Side, "order at price %f should be %s", a.Price.Float64(), a.Side) + } +} diff --git a/pkg/testing/testhelper/number.go b/pkg/testing/testhelper/number.go new file mode 100644 index 000000000..e57659a01 --- /dev/null +++ b/pkg/testing/testhelper/number.go @@ -0,0 +1,18 @@ +package testhelper + +import "github.com/c9s/bbgo/pkg/fixedpoint" + +func Number(a interface{}) fixedpoint.Value { + switch v := a.(type) { + case string: + return fixedpoint.MustNewFromString(v) + case int: + return fixedpoint.NewFromInt(int64(v)) + case int64: + return fixedpoint.NewFromInt(int64(v)) + case float64: + return fixedpoint.NewFromFloat(v) + } + + return fixedpoint.Zero +}