From 348c8a61e418adc0b5ce22a56f0625960d06244b Mon Sep 17 00:00:00 2001 From: c9s Date: Sat, 5 Aug 2023 01:59:20 +0800 Subject: [PATCH] add convert strategy --- pkg/strategy/convert/strategy.go | 421 +++++++++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 pkg/strategy/convert/strategy.go diff --git a/pkg/strategy/convert/strategy.go b/pkg/strategy/convert/strategy.go new file mode 100644 index 000000000..c5d620af8 --- /dev/null +++ b/pkg/strategy/convert/strategy.go @@ -0,0 +1,421 @@ +package convert + +import ( + "context" + "fmt" + "strconv" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + "github.com/c9s/bbgo/pkg/util/tradingutil" +) + +const ID = "convert" + +var log = logrus.WithField("strategy", ID) + +var stableCoins = []string{"USDT", "USDC"} + +func init() { + bbgo.RegisterStrategy(ID, &Strategy{}) +} + +// Strategy "convert" converts your specific asset into other asset +type Strategy struct { + Market types.Market + + From string `json:"from"` + To string `json:"to"` + + // Interval is the period that you want to submit order + Interval types.Interval `json:"interval"` + + UseLimitOrder bool `json:"useLimitOrder"` + + UseTakerOrder bool `json:"useTakerOrder"` + + MinBalance fixedpoint.Value `json:"minBalance"` + MaxQuantity fixedpoint.Value `json:"maxQuantity"` + + Position *types.Position `persistence:"position"` + + directMarket *types.Market + indirectMarkets []types.Market + + markets map[string]types.Market + session *bbgo.ExchangeSession + orderExecutor *bbgo.SimpleOrderExecutor + + pendingQuantity map[string]fixedpoint.Value + pendingQuantityLock sync.Mutex +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s-%s", ID, s.From, s.To) +} + +func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { + +} + +func (s *Strategy) Validate() error { + return nil +} + +func (s *Strategy) handleOrderFilled(ctx context.Context, order types.Order) { + var fees = map[string]fixedpoint.Value{} + + if service, ok := s.session.Exchange.(types.ExchangeOrderQueryService); ok { + trades, err := service.QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: order.Symbol, + OrderID: strconv.FormatUint(order.OrderID, 10), + }) + + if err != nil { + return + } + + fees = tradingutil.CollectTradeFee(trades) + log.Infof("aggregated order fees: %+v", fees) + } + + if s.directMarket != nil { + if order.Symbol != s.directMarket.Symbol { + return + } + + // TODO: notification + return + } else if len(s.indirectMarkets) > 0 { + for i := 0; i < len(s.indirectMarkets); i++ { + market := s.indirectMarkets[i] + if market.Symbol != order.Symbol { + continue + } + + if i == len(s.indirectMarkets)-1 { + // TODO: handle the final order here + continue + } + + nextMarket := s.indirectMarkets[i+1] + + ticker, err := s.session.Exchange.QueryTicker(ctx, nextMarket.Symbol) + if err != nil { + log.WithError(err).Errorf("unable to query ticker") + return + } + + quantity := order.Quantity + quoteQuantity := quantity.Mul(order.Price) + + switch order.Side { + case types.SideTypeSell: + // convert quote asset + if quoteFee, ok := fees[market.QuoteCurrency]; ok { + quoteQuantity = quoteQuantity.Sub(quoteFee) + } + + if err := s.convertBalance(ctx, market.QuoteCurrency, quoteQuantity, nextMarket, ticker); err != nil { + log.WithError(err).Errorf("unable to convert balance") + } + + case types.SideTypeBuy: + if baseFee, ok := fees[market.BaseCurrency]; ok { + quantity = quantity.Sub(baseFee) + } + + if err := s.convertBalance(ctx, market.BaseCurrency, quantity, nextMarket, ticker); err != nil { + log.WithError(err).Errorf("unable to convert balance") + } + } + } + } + +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + s.pendingQuantity = make(map[string]fixedpoint.Value) + s.session = session + s.markets = session.Markets() + + if market, ok := findDirectMarket(s.markets, s.From, s.To); ok { + s.directMarket = &market + } else if marketChain, ok := findIndirectMarket(s.markets, s.From, s.To); ok { + s.indirectMarkets = marketChain + } + + s.orderExecutor = bbgo.NewSimpleOrderExecutor(session) + s.orderExecutor.ActiveMakerOrders().OnFilled(func(o types.Order) { + s.handleOrderFilled(ctx, o) + }) + s.orderExecutor.Bind() + + if s.Interval != "" { + session.UserDataStream.OnStart(func() { + go s.tickWatcher(ctx, s.Interval.Duration()) + }) + } + + return nil +} + +func (s *Strategy) tickWatcher(ctx context.Context, interval time.Duration) { + if err := s.convert(ctx); err != nil { + log.WithError(err).Errorf("unable to convert asset %s", s.From) + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + if err := s.convert(ctx); err != nil { + log.WithError(err).Errorf("unable to convert asset %s", s.From) + } + } + } +} + +func (s *Strategy) getSourceMarket() (types.Market, bool) { + if s.directMarket != nil { + return *s.directMarket, true + } else if len(s.indirectMarkets) > 0 { + return s.indirectMarkets[0], true + } + + return types.Market{}, false +} + +// convert triggers a convert order +func (s *Strategy) convert(ctx context.Context) error { + account := s.session.GetAccount() + fromAsset, ok := account.Balance(s.From) + if !ok { + return nil + } + + log.Debugf("converting %s to %s, current balance: %+v", s.From, s.To, fromAsset) + + if sourceMarket, ok := s.getSourceMarket(); ok { + ticker, err := s.session.Exchange.QueryTicker(ctx, sourceMarket.Symbol) + if err != nil { + return err + } + + quantity := fromAsset.Available + + if !s.MinBalance.IsZero() { + quantity = quantity.Sub(s.MinBalance) + if quantity.Sign() < 0 { + return nil + } + } + + if !s.MaxQuantity.IsZero() { + quantity = fixedpoint.Min(s.MaxQuantity, quantity) + } + + if err := s.convertBalance(ctx, fromAsset.Currency, quantity, sourceMarket, ticker); err != nil { + return err + } + } + + return nil +} + +func (s *Strategy) collectPendingQuantity() { + s.pendingQuantityLock.Lock() + defer s.pendingQuantityLock.Unlock() + + activeOrders := s.orderExecutor.ActiveMakerOrders().Orders() + for _, o := range activeOrders { + if m, ok := s.markets[o.Symbol]; ok { + switch o.Side { + case types.SideTypeBuy: + qq := o.Quantity.Sub(o.ExecutedQuantity).Mul(o.Price) + if q2, ok := s.pendingQuantity[m.QuoteCurrency]; ok { + s.pendingQuantity[m.QuoteCurrency] = q2.Add(qq) + } else { + s.pendingQuantity[m.QuoteCurrency] = qq + } + case types.SideTypeSell: + q := o.Quantity.Sub(o.ExecutedQuantity) + if q2, ok := s.pendingQuantity[m.BaseCurrency]; ok { + s.pendingQuantity[m.BaseCurrency] = q2.Add(q) + } else { + s.pendingQuantity[m.BaseCurrency] = q + } + } + } + } +} + +func (s *Strategy) convertBalance(ctx context.Context, fromAsset string, available fixedpoint.Value, market types.Market, ticker *types.Ticker) error { + s.collectPendingQuantity() + + if err := s.orderExecutor.CancelOrders(ctx); err != nil { + log.WithError(err).Warn("unable to cancel orders") + } + + s.pendingQuantityLock.Lock() + if pendingQ, ok := s.pendingQuantity[fromAsset]; ok { + available = available.Add(pendingQ) + + delete(s.pendingQuantity, fromAsset) + } + s.pendingQuantityLock.Unlock() + + switch fromAsset { + + case market.BaseCurrency: + log.Infof("converting %s %s to %s...", available, fromAsset, market.QuoteCurrency) + + available = market.TruncateQuantity(available) + + // from = Base -> action = sell + if available.Compare(market.MinQuantity) < 0 { + log.Debugf("asset %s %s is less than minQuantity %s, skip convert", available, fromAsset, market.MinQuantity) + return nil + } + + price := ticker.Sell + if s.UseTakerOrder { + price = ticker.Buy + } + + quoteAmount := price.Mul(available) + if quoteAmount.Compare(market.MinNotional) < 0 { + log.Debugf("asset %s %s (%s %s) is less than minNotional %s, skip convert", + available, fromAsset, + quoteAmount, market.QuoteCurrency, + market.MinNotional) + return nil + } + + orderForm := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Quantity: available, + Price: price, + Market: market, + TimeInForce: types.TimeInForceGTC, + } + if _, err := s.orderExecutor.SubmitOrders(ctx, orderForm); err != nil { + log.WithError(err).Errorf("unable to submit order: %+v", orderForm) + } + + case market.QuoteCurrency: + log.Infof("converting %s %s to %s...", available, fromAsset, market.BaseCurrency) + + available = market.TruncateQuoteQuantity(available) + + // from = Quote -> action = buy + if available.Compare(market.MinNotional) < 0 { + log.Debugf("asset %s %s is less than minNotional %s, skip convert", available, fromAsset, market.MinNotional) + return nil + } + + price := ticker.Buy + if s.UseTakerOrder { + price = ticker.Sell + } + + quantity := available.Div(price) + quantity = market.TruncateQuantity(quantity) + if quantity.Compare(market.MinQuantity) < 0 { + log.Debugf("asset %s %s is less than minQuantity %s, skip convert", + quantity, fromAsset, + market.MinQuantity) + return nil + } + + notional := quantity.Mul(price) + if notional.Compare(market.MinNotional) < 0 { + log.Debugf("asset %s %s (%s %s) is less than minNotional %s, skip convert", + quantity, fromAsset, + notional, market.QuoteCurrency, + market.MinNotional) + return nil + } + + orderForm := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + Market: market, + TimeInForce: types.TimeInForceGTC, + } + if _, err := s.orderExecutor.SubmitOrders(ctx, orderForm); err != nil { + log.WithError(err).Errorf("unable to submit order: %+v", orderForm) + } + } + + return nil +} + +func findIndirectMarket(markets map[string]types.Market, from, to string) ([]types.Market, bool) { + var sourceMarkets = map[string]types.Market{} + var targetMarkets = map[string]types.Market{} + + for _, market := range markets { + if market.BaseCurrency == from { + sourceMarkets[market.QuoteCurrency] = market + } else if market.QuoteCurrency == from { + sourceMarkets[market.BaseCurrency] = market + } + + if market.BaseCurrency == to { + targetMarkets[market.QuoteCurrency] = market + } else if market.QuoteCurrency == to { + targetMarkets[market.BaseCurrency] = market + } + } + + // prefer stable coins for better liquidity + for _, stableCoin := range stableCoins { + m1, ok1 := sourceMarkets[stableCoin] + m2, ok2 := targetMarkets[stableCoin] + if ok1 && ok2 { + return []types.Market{m1, m2}, true + } + } + + for sourceCurrency, m1 := range sourceMarkets { + if m2, ok := targetMarkets[sourceCurrency]; ok { + return []types.Market{m1, m2}, true + } + } + + return nil, false +} + +func findDirectMarket(markets map[string]types.Market, from, to string) (types.Market, bool) { + symbol := from + to + if m, ok := markets[symbol]; ok { + return m, true + } + + symbol = to + from + if m, ok := markets[symbol]; ok { + return m, true + } + + return types.Market{}, false +}