diff --git a/.gitignore b/.gitignore index f41e4ff8a..282b3fd3d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ /bbgow* /config/bbgo.yaml +/config/*.local.yaml +/config/*.yaml.local /localconfig @@ -66,3 +68,5 @@ coverage_dum.txt /.chglog/ /.credentials + + diff --git a/config/autoborrow.yaml b/config/autoborrow.yaml index 33352a01b..9eb4f1629 100644 --- a/config/autoborrow.yaml +++ b/config/autoborrow.yaml @@ -1,6 +1,12 @@ --- +sessions: + binance_margin: + exchange: binance + envVarPrefix: BINANCE + margin: true + exchangeStrategies: -- on: binance +- on: binance_margin autoborrow: interval: 30m autoRepayWhenDeposit: true @@ -19,6 +25,15 @@ exchangeStrategies: - '<@USER_ID>' - '' + marginHighInterestRateAlert: + interval: 5m + minAnnualInterestRate: "5%" + slack: + channel: "channel_id" + mentions: + - '<@USER_ID>' + - '' + marginLevelAlert: interval: 5m minMargin: 2.0 diff --git a/pkg/exchange/binance/binanceapi/client_test.go b/pkg/exchange/binance/binanceapi/client_test.go index bad92bce3..530113819 100644 --- a/pkg/exchange/binance/binanceapi/client_test.go +++ b/pkg/exchange/binance/binanceapi/client_test.go @@ -52,7 +52,7 @@ func TestClient_GetMarginFutureNextHourlyInterestRate(t *testing.T) { err := client.SetTimeOffsetFromServer(ctx) if assert.NoError(t, err) { req := client.NewGetMarginFutureHourlyInterestRateRequest(). - Assets("BTC,USDT"). + Assets("BTC,ETH,USDT,USDC"). IsIsolated("FALSE") rates, err := req.Do(ctx) assert.NoError(t, err) diff --git a/pkg/exchange/binance/exchange.go b/pkg/exchange/binance/exchange.go index ba017441c..8ac3c34cc 100644 --- a/pkg/exchange/binance/exchange.go +++ b/pkg/exchange/binance/exchange.go @@ -309,6 +309,11 @@ func (e *Exchange) NewStream() types.Stream { func (e *Exchange) QueryMarginFutureHourlyInterestRate( ctx context.Context, assets []string, ) (rates types.MarginNextHourlyInterestRateMap, err error) { + + if len(assets) > 20 { + return nil, fmt.Errorf("assets length must be less than 20, got %d", len(assets)) + } + req := e.client2.NewGetMarginFutureHourlyInterestRateRequest() req.Assets(strings.Join(assets, ",")) req.IsIsolated("FALSE") diff --git a/pkg/strategy/autoborrow/alert_interest_rate.go b/pkg/strategy/autoborrow/alert_interest_rate.go new file mode 100644 index 000000000..0e62480bb --- /dev/null +++ b/pkg/strategy/autoborrow/alert_interest_rate.go @@ -0,0 +1,239 @@ +package autoborrow + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/slack-go/slack" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/livenote" + "github.com/c9s/bbgo/pkg/types" + "github.com/c9s/bbgo/pkg/types/currency" +) + +type MarginHighInterestRateAlert struct { + AlertID string + AccountLabel string + + SessionName string + Exchange types.ExchangeName + + HighRateAssets types.MarginNextHourlyInterestRateMap + + NextTotalInterestValueInUSD fixedpoint.Value + Debts types.BalanceMap +} + +func (a *MarginHighInterestRateAlert) ObjectID() string { + return a.AlertID +} + +func (a *MarginHighInterestRateAlert) SlackAttachment() slack.Attachment { + var fields []slack.AttachmentField + + if len(a.AccountLabel) > 0 { + fields = append(fields, slack.AttachmentField{ + Title: "Account", + Value: a.AccountLabel, + }) + } + + if len(a.SessionName) > 0 { + fields = append(fields, slack.AttachmentField{ + Title: "Session", + Value: a.SessionName, + }) + } + + if len(a.Exchange) > 0 { + fields = append(fields, slack.AttachmentField{ + Title: "Exchange", + Value: string(a.Exchange), + }) + } + + isRisky := len(a.HighRateAssets) > 0 + + color := "good" + text := "✅ No high interest rate assets found" + + if isRisky { + color = "warning" + text = fmt.Sprintf("💹 %d high interest rate assets found", len(a.HighRateAssets)) + } + + for asset, rate := range a.HighRateAssets { + desc := "APY: " + rate.AnnualizedRate.FormatPercentage(2) + if debt, ok := a.Debts[asset]; ok { + desc += " Debt: " + debt.Debt().String() + } + + fields = append(fields, slack.AttachmentField{ + Title: asset, + Value: desc, + }) + } + + if a.NextTotalInterestValueInUSD.Sign() > 0 { + fields = append(fields, slack.AttachmentField{ + Title: "Total Interest Value In USD", + Value: a.NextTotalInterestValueInUSD.String(), + }) + } + + return slack.Attachment{ + Color: color, + Title: "High Interest Rate Alert", + Text: text, + Fields: fields, + MarkdownIn: []string{"text"}, + } +} + +type marginFutureInterestQueryService interface { + QueryMarginFutureHourlyInterestRate(ctx context.Context, assets []string) (rates types.MarginNextHourlyInterestRateMap, err error) +} + +type MarginHighInterestRateWorker struct { + strategy *Strategy + session *bbgo.ExchangeSession + config *MarginHighInterestRateAlertConfig + + service marginFutureInterestQueryService +} + +func newMarginHighInterestRateWorker(strategy *Strategy, config *MarginHighInterestRateAlertConfig) *MarginHighInterestRateWorker { + session := strategy.ExchangeSession + service, support := session.Exchange.(marginFutureInterestQueryService) + if !support { + log.Warnf("exchange %T does not support margin future interest rate query", session.Exchange) + } + + return &MarginHighInterestRateWorker{ + strategy: strategy, + session: session, + config: config, + service: service, + } +} + +func (w *MarginHighInterestRateWorker) findMarginHighInterestRateAssets( + rateMap types.MarginNextHourlyInterestRateMap, + minAnnualRate float64, +) (highRates types.MarginNextHourlyInterestRateMap, err error) { + highRates = make(types.MarginNextHourlyInterestRateMap) + for asset, rate := range rateMap { + if rate.AnnualizedRate.IsZero() { + log.Warnf("annualized rate is zero for %s", asset) + } + + if rate.AnnualizedRate.Float64() >= minAnnualRate { + highRates[asset] = rate + } + } + + return highRates, nil +} + +func (w *MarginHighInterestRateWorker) Run(ctx context.Context) { + alertInterval := time.Minute * 5 + if w.config.Interval > 0 { + alertInterval = w.config.Interval.Duration() + } + + if w.service == nil { + log.Warnf("exchange %T does not support margin future interest rate query", w.session.Exchange) + return + } + + ticker := time.NewTicker(alertInterval) + defer ticker.Stop() + + danger := false + + // alertId is used to identify the alert message when the alert is solved, we + // should send a new alert message instead of replacing the previous one, so the + // alertId will be updated to a new uuid once the alert is solved + alertId := uuid.New().String() + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + assets := w.strategy.getAssetStringSlice() + + rateMap, err := w.service.QueryMarginFutureHourlyInterestRate(ctx, assets) + if err != nil { + log.WithError(err).Errorf("unable to query the next future hourly interest rate") + continue + } + + log.Infof("rates: %+v", rateMap) + + highRateAssets, err := w.findMarginHighInterestRateAssets(rateMap, w.config.MinAnnualInterestRate.Float64()) + if err != nil { + log.WithError(err).Errorf("unable to query the next future hourly interest rate") + continue + } + + log.Infof("found high interest rate assets: %+v", highRateAssets) + + shouldAlert := func() bool { return len(highRateAssets) > 0 } + + // either danger or margin level is less than the minimal margin level + // if the previous danger is set to true, we should send the alert again to + // update the previous danger margin alert message + if danger || shouldAlert() { + // calculate the debt value by the price solver + nextTotalInterestValue := fixedpoint.Zero + debts := w.session.Account.Balances().Debts() + for cur, bal := range debts { + price, ok := w.session.GetPriceSolver().ResolvePrice(cur, currency.USDT) + if !ok { + log.Warnf("unable to resolve price for %s", cur) + continue + } + + rate := rateMap[cur] + nextTotalInterestValue = nextTotalInterestValue.Add( + bal.Debt().Mul(rate.HourlyRate).Mul(price)) + } + + alert := &MarginHighInterestRateAlert{ + AlertID: alertId, + AccountLabel: w.session.GetAccountLabel(), + Exchange: w.session.ExchangeName, + SessionName: w.session.Name, + Debts: debts, + HighRateAssets: highRateAssets, + } + + bbgo.PostLiveNote(alert, + livenote.Channel(w.config.Slack.Channel), + livenote.OneTimeMention(w.config.Slack.Mentions...), + livenote.CompareObject(true), + ) + + // if the previous danger flag is not set, we should send the alert at the first time + if !danger { + w.strategy.postLiveNoteMessage(alert, w.config.Slack, "⚠️ High interest rate assets found, please repay the debt") + } + + // update danger flag + danger = shouldAlert() + + // if it's not in danger anymore, send a solved message + if !danger { + alertId = uuid.New().String() + w.strategy.postLiveNoteMessage(alert, w.config.Slack, "✅ High interest rate alert is solved") + } + } + } + } +} diff --git a/pkg/strategy/autoborrow/alert_margin_level.go b/pkg/strategy/autoborrow/alert_margin_level.go index 603452ba6..c7821c929 100644 --- a/pkg/strategy/autoborrow/alert_margin_level.go +++ b/pkg/strategy/autoborrow/alert_margin_level.go @@ -1,22 +1,21 @@ package autoborrow import ( + "context" "fmt" "time" + "github.com/google/uuid" "github.com/slack-go/slack" + "github.com/c9s/bbgo/pkg/bbgo" "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/livenote" "github.com/c9s/bbgo/pkg/slack/slackalert" "github.com/c9s/bbgo/pkg/types" + "github.com/c9s/bbgo/pkg/types/currency" ) -type MarginLevelAlertConfig struct { - Slack *slackalert.SlackAlert `json:"slack"` - Interval types.Duration `json:"interval"` - MinMargin fixedpoint.Value `json:"minMargin"` -} - // MarginLevelAlert is used to send the slack mention alerts when the current margin is less than the required margin level type MarginLevelAlert struct { AlertID string @@ -115,3 +114,92 @@ func (m *MarginLevelAlert) SlackAttachment() slack.Attachment { FooterIcon: types.ExchangeFooterIcon(m.Exchange), } } + +type MarginLevelAlertConfig struct { + Slack *slackalert.SlackAlert `json:"slack"` + Interval types.Duration `json:"interval"` + MinMargin fixedpoint.Value `json:"minMargin"` +} + +func (s *Strategy) marginLevelAlertWorker(ctx context.Context, config *MarginLevelAlertConfig) { + alertInterval := time.Minute * 5 + if config.Interval > 0 { + alertInterval = config.Interval.Duration() + } + + ticker := time.NewTicker(alertInterval) + defer ticker.Stop() + + danger := false + + // alertId is used to identify the alert message when the alert is solved, we + // should send a new alert message instead of replacing the previous one, so the + // alertId will be updated to a new uuid once the alert is solved + alertId := uuid.New().String() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + account, err := s.ExchangeSession.UpdateAccount(ctx) + if err != nil { + log.WithError(err).Errorf("unable to update account") + continue + } + + // either danger or margin level is less than the minimal margin level + // if the previous danger is set to true, we should send the alert again to + // update the previous danger margin alert message + if danger || account.MarginLevel.Compare(config.MinMargin) <= 0 { + // calculate the debt value by the price solver + totalDebtValueInUSDT := fixedpoint.Zero + debts := account.Balances().Debts() + for cur, bal := range debts { + price, ok := s.ExchangeSession.GetPriceSolver().ResolvePrice(cur, currency.USDT) + if !ok { + log.Warnf("unable to resolve price for %s", cur) + continue + } + + debtValue := bal.Debt().Mul(price) + totalDebtValueInUSDT = totalDebtValueInUSDT.Add(debtValue) + } + + alert := &MarginLevelAlert{ + AlertID: alertId, + AccountLabel: s.ExchangeSession.GetAccountLabel(), + Exchange: s.ExchangeSession.ExchangeName, + CurrentMarginLevel: account.MarginLevel, + MinimalMarginLevel: config.MinMargin, + SessionName: s.ExchangeSession.Name, + TotalDebtValueInUSD: totalDebtValueInUSDT, + Debts: account.Balances().Debts(), + } + + bbgo.PostLiveNote(alert, + livenote.Channel(config.Slack.Channel), + livenote.OneTimeMention(config.Slack.Mentions...), + livenote.CompareObject(true), + ) + + // if the previous danger flag is not set, we should send the alert at the first time + if !danger { + s.postLiveNoteMessage(alert, config.Slack, "⚠️ The current margin level %f is less than the minimal margin level %f, please repay the debt", + account.MarginLevel.Float64(), + config.MinMargin.Float64()) + + } + + // update danger flag + danger = account.MarginLevel.Compare(config.MinMargin) <= 0 + + // if it's not in danger anymore, send a solved message + if !danger { + alertId = uuid.New().String() + s.postLiveNoteMessage(alert, config.Slack, "✅ The current margin level %f is safe now", account.MarginLevel.Float64()) + } + } + } + } +} diff --git a/pkg/strategy/autoborrow/strategy.go b/pkg/strategy/autoborrow/strategy.go index f610d84d5..1576bd9eb 100644 --- a/pkg/strategy/autoborrow/strategy.go +++ b/pkg/strategy/autoborrow/strategy.go @@ -5,7 +5,6 @@ import ( "fmt" "time" - "github.com/google/uuid" "github.com/sirupsen/logrus" "github.com/slack-go/slack" @@ -13,10 +12,9 @@ import ( "github.com/c9s/bbgo/pkg/exchange/binance" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/livenote" - "github.com/c9s/bbgo/pkg/pricesolver" "github.com/c9s/bbgo/pkg/slack/slackalert" "github.com/c9s/bbgo/pkg/types" - currency2 "github.com/c9s/bbgo/pkg/types/currency" + "github.com/c9s/bbgo/pkg/types/currency" ) const ID = "autoborrow" @@ -61,6 +59,14 @@ type MarginRepayAlertConfig struct { Slack *slackalert.SlackAlert `json:"slack,omitempty"` } +type MarginHighInterestRateAlertConfig struct { + Slack *slackalert.SlackAlert `json:"slack,omitempty"` + + Interval types.Duration `json:"interval"` + + MinAnnualInterestRate fixedpoint.Value `json:"minAnnualInterestRate"` +} + type Strategy struct { Interval types.Interval `json:"interval"` MinMarginLevel fixedpoint.Value `json:"minMarginLevel"` @@ -68,15 +74,16 @@ type Strategy struct { AutoRepayWhenDeposit bool `json:"autoRepayWhenDeposit"` MarginLevelAlert *MarginLevelAlertConfig `json:"marginLevelAlert"` + MarginRepayAlert *MarginRepayAlertConfig `json:"marginRepayAlert"` + MarginHighInterestRateAlertConfig *MarginHighInterestRateAlertConfig `json:"marginHighInterestRateAlert"` + Assets []MarginAssetConfig `json:"assets"` ExchangeSession *bbgo.ExchangeSession marginBorrowRepay types.MarginBorrowRepayService - - priceSolver *pricesolver.SimplePriceSolver } func (s *Strategy) ID() string { @@ -84,7 +91,24 @@ func (s *Strategy) ID() string { } func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { - // session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) + markets := session.Markets() + for _, asset := range s.Assets { + if currency.IsFiatCurrency(asset.Asset) { + continue + } + + if market, ok := markets.FindPair(asset.Asset, currency.USDT); ok { + session.Subscribe(types.KLineChannel, market.Symbol, types.SubscribeOptions{Interval: types.Interval5m}) + } + } +} + +func (s *Strategy) getAssetStringSlice() []string { + var assets []string + for _, a := range s.Assets { + assets = append(assets, a.Asset) + } + return assets } func (s *Strategy) tryToRepayAnyDebt(ctx context.Context) { @@ -578,106 +602,17 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } if s.MarginLevelAlert != nil && !s.MarginLevelAlert.MinMargin.IsZero() { - alertInterval := time.Minute * 5 - if s.MarginLevelAlert.Interval > 0 { - alertInterval = s.MarginLevelAlert.Interval.Duration() - } - - go s.marginAlertWorker(ctx, alertInterval) + go s.marginLevelAlertWorker(ctx, s.MarginLevelAlert) } - markets := session.Markets() - - s.priceSolver = pricesolver.NewSimplePriceResolver(markets) - s.priceSolver.BindStream(session.MarketDataStream) - s.priceSolver.BindStream(session.UserDataStream) + if s.MarginHighInterestRateAlertConfig != nil { + go newMarginHighInterestRateWorker(s, s.MarginHighInterestRateAlertConfig).Run(ctx) + } go s.run(ctx, s.Interval.Duration()) return nil } -func (s *Strategy) marginAlertWorker(ctx context.Context, alertInterval time.Duration) { - if s.MarginLevelAlert == nil { - return - } - - ticker := time.NewTicker(alertInterval) - defer ticker.Stop() - - danger := false - - // alertId is used to identify the alert message when the alert is solved, we - // should send a new alert message instead of replacing the previous one, so the - // alertId will be updated to a new uuid once the alert is solved - alertId := uuid.New().String() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - account, err := s.ExchangeSession.UpdateAccount(ctx) - if err != nil { - log.WithError(err).Errorf("unable to update account") - continue - } - - // either danger or margin level is less than the minimal margin level - // if the previous danger is set to true, we should send the alert again to - // update the previous danger margin alert message - if danger || account.MarginLevel.Compare(s.MarginLevelAlert.MinMargin) <= 0 { - // calculate the debt value by the price solver - totalDebtValueInUSDT := fixedpoint.Zero - debts := account.Balances().Debts() - for currency, bal := range debts { - price, ok := s.priceSolver.ResolvePrice(currency, currency2.USDT) - if !ok { - log.Warnf("unable to resolve price for %s", currency) - continue - } - - debtValue := bal.Debt().Mul(price) - totalDebtValueInUSDT = totalDebtValueInUSDT.Add(debtValue) - } - - alert := &MarginLevelAlert{ - AlertID: alertId, - AccountLabel: s.ExchangeSession.GetAccountLabel(), - Exchange: s.ExchangeSession.ExchangeName, - CurrentMarginLevel: account.MarginLevel, - MinimalMarginLevel: s.MarginLevelAlert.MinMargin, - SessionName: s.ExchangeSession.Name, - TotalDebtValueInUSD: totalDebtValueInUSDT, - Debts: account.Balances().Debts(), - } - - bbgo.PostLiveNote(alert, - livenote.Channel(s.MarginLevelAlert.Slack.Channel), - livenote.OneTimeMention(s.MarginLevelAlert.Slack.Mentions...), - livenote.CompareObject(true), - ) - - // if the previous danger flag is not set, we should send the alert at the first time - if !danger { - s.postLiveNoteMessage(alert, s.MarginLevelAlert.Slack, "⚠️ The current margin level %f is less than the minimal margin level %f, please repay the debt", - account.MarginLevel.Float64(), - s.MarginLevelAlert.MinMargin.Float64()) - - } - - // update danger flag - danger = account.MarginLevel.Compare(s.MarginLevelAlert.MinMargin) <= 0 - - // if it's not in danger anymore, send a solved message - if !danger { - alertId = uuid.New().String() - s.postLiveNoteMessage(alert, s.MarginLevelAlert.Slack, "✅ The current margin level %f is safe now", account.MarginLevel.Float64()) - } - } - } - } -} - func (s *Strategy) postLiveNoteMessage(obj livenote.Object, alert *slackalert.SlackAlert, msgf string, args ...any) { log.Infof(msgf, args...) diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index 5fdb7b940..79860ddee 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -2135,18 +2135,24 @@ func (s *Strategy) CrossRun( } }) - // TODO: remove this nil value behavior, check all OnProfit usage and remove the EmitProfit call with nil profit + shouldNotifyProfit := func(trade types.Trade, profit *types.Profit) bool { + amountThreshold := s.NotifyIgnoreSmallAmountProfitTrade + if amountThreshold.IsZero() { + return true + } else if trade.QuoteQuantity.Sign() > 0 && trade.QuoteQuantity.Compare(amountThreshold) >= 0 { + return true + } + + return false + } + s.tradeCollector.OnProfit(func(trade types.Trade, profit *types.Profit) { if profit != nil { if s.CircuitBreaker != nil { s.CircuitBreaker.RecordProfit(profit.Profit, trade.Time.Time()) } - if amount := s.NotifyIgnoreSmallAmountProfitTrade; amount.Sign() > 0 { - if profit.QuoteQuantity.Compare(amount) > 0 { - bbgo.Notify(profit) - } - } else { + if shouldNotifyProfit(trade, profit) { bbgo.Notify(profit) } diff --git a/pkg/types/margin.go b/pkg/types/margin.go index 7d3e5086b..8a0754f53 100644 --- a/pkg/types/margin.go +++ b/pkg/types/margin.go @@ -172,3 +172,11 @@ type MarginNextHourlyInterestRate struct { } type MarginNextHourlyInterestRateMap map[string]*MarginNextHourlyInterestRate + +func (m MarginNextHourlyInterestRateMap) String() (out string) { + for k, v := range m { + out += k + " Hourly:" + v.HourlyRate.FormatPercentage(5) + " APY: " + v.AnnualizedRate.FormatPercentage(3) + "\n" + } + + return out +} diff --git a/pkg/types/market.go b/pkg/types/market.go index 9d731de8c..fa85ae1d7 100644 --- a/pkg/types/market.go +++ b/pkg/types/market.go @@ -276,6 +276,20 @@ func (m MarketMap) Has(symbol string) bool { return ok } +func (m MarketMap) FindPair(asset, quote string) (Market, bool) { + symbol := asset + quote + if market, ok := m[symbol]; ok { + return market, true + } + + reversedSymbol := asset + quote + if market, ok := m[reversedSymbol]; ok { + return market, true + } + + return Market{}, false +} + // FindAssetMarkets returns the markets that contains the given asset func (m MarketMap) FindAssetMarkets(asset string) MarketMap { var markets = make(MarketMap)