Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEATURE: [autoborrow] add high interest rate alert #1875

Merged
merged 10 commits into from
Dec 27, 2024
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
/bbgow*

/config/bbgo.yaml
/config/*.local.yaml
/config/*.yaml.local

/localconfig

Expand Down Expand Up @@ -66,3 +68,5 @@ coverage_dum.txt
/.chglog/

/.credentials


17 changes: 16 additions & 1 deletion config/autoborrow.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
---
sessions:
binance_margin:
exchange: binance
envVarPrefix: BINANCE
margin: true

exchangeStrategies:
- on: binance
- on: binance_margin
autoborrow:
interval: 30m
autoRepayWhenDeposit: true
Expand All @@ -19,6 +25,15 @@ exchangeStrategies:
- '<@USER_ID>'
- '<!subteam^TEAM_ID>'

marginHighInterestRateAlert:
interval: 5m
minAnnualInterestRate: "5%"
slack:
channel: "channel_id"
mentions:
- '<@USER_ID>'
- '<!subteam^TEAM_ID>'

marginLevelAlert:
interval: 5m
minMargin: 2.0
Expand Down
2 changes: 1 addition & 1 deletion pkg/exchange/binance/binanceapi/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions pkg/exchange/binance/exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
239 changes: 239 additions & 0 deletions pkg/strategy/autoborrow/alert_interest_rate.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
}
}
Loading
Loading