package xbalance import ( "context" "encoding/json" "fmt" "sync" "time" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/slack-go/slack" "github.com/c9s/bbgo/pkg/bbgo" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/types" "github.com/c9s/bbgo/pkg/util/templateutil" "github.com/c9s/bbgo/pkg/util/timejitter" ) const ID = "xbalance" const stateKey = "state-v1" var priceFixer = fixedpoint.NewFromFloat(0.99) func init() { bbgo.RegisterStrategy(ID, &Strategy{}) } type State struct { Asset string `json:"asset"` DailyNumberOfTransfers int `json:"dailyNumberOfTransfers,omitempty"` DailyAmountOfTransfers fixedpoint.Value `json:"dailyAmountOfTransfers,omitempty"` Since int64 `json:"since"` } func (s *State) IsOver24Hours() bool { return time.Since(time.Unix(s.Since, 0)) >= 24*time.Hour } func (s *State) PlainText() string { return templateutil.Render(`{{ .Asset }} transfer stats: daily number of transfers: {{ .DailyNumberOfTransfers }} daily amount of transfers {{ .DailyAmountOfTransfers.Float64 }}`, s) } func (s *State) SlackAttachment() slack.Attachment { return slack.Attachment{ // Pretext: "", // Text: text, Title: s.Asset + " Transfer States", Fields: []slack.AttachmentField{ {Title: "Total Number of Transfers", Value: fmt.Sprintf("%d", s.DailyNumberOfTransfers), Short: true}, {Title: "Total Amount of Transfers", Value: s.DailyAmountOfTransfers.String(), Short: true}, }, Footer: templateutil.Render("Since {{ . }}", time.Unix(s.Since, 0).Format(time.RFC822)), } } func (s *State) Reset() { var beginningOfTheDay = types.BeginningOfTheDay(time.Now().Local()) *s = State{ DailyNumberOfTransfers: 0, DailyAmountOfTransfers: fixedpoint.Zero, Since: beginningOfTheDay.Unix(), } } type WithdrawalRequest struct { FromSession string `json:"fromSession"` ToSession string `json:"toSession"` Asset string `json:"asset"` Amount fixedpoint.Value `json:"amount"` } func (r *WithdrawalRequest) String() string { return fmt.Sprintf("WITHDRAWAL REQUEST: sending %s %s from %s -> %s", r.Amount.FormatString(4), r.Asset, r.FromSession, r.ToSession, ) } func (r *WithdrawalRequest) PlainText() string { return fmt.Sprintf("Withdraw request: sending %s %s from %s -> %s", r.Amount.FormatString(4), r.Asset, r.FromSession, r.ToSession, ) } func (r *WithdrawalRequest) SlackAttachment() slack.Attachment { var color = "#DC143C" title := templateutil.Render(`Withdraw Request {{ .Asset }}`, r) return slack.Attachment{ // Pretext: "", // Text: text, Title: title, Color: color, Fields: []slack.AttachmentField{ {Title: "Asset", Value: r.Asset, Short: true}, {Title: "Amount", Value: r.Amount.FormatString(4), Short: true}, {Title: "From", Value: r.FromSession}, {Title: "To", Value: r.ToSession}, }, Footer: templateutil.Render("Time {{ . }}", time.Now().Format(time.RFC822)), // FooterIcon: "", } } type Address struct { Address string `json:"address"` AddressTag string `json:"addressTag"` Network string `json:"network"` ForeignFee fixedpoint.Value `json:"foreignFee"` } func (a *Address) UnmarshalJSON(body []byte) error { var arg interface{} err := json.Unmarshal(body, &arg) if err != nil { return err } switch argT := arg.(type) { case string: a.Address = argT return nil } type addressTemplate Address return json.Unmarshal(body, (*addressTemplate)(a)) } type Strategy struct { Interval types.Duration `json:"interval"` Addresses map[string]Address `json:"addresses"` MaxDailyNumberOfTransfer int `json:"maxDailyNumberOfTransfer"` MaxDailyAmountOfTransfer fixedpoint.Value `json:"maxDailyAmountOfTransfer"` CheckOnStart bool `json:"checkOnStart"` Asset string `json:"asset"` // Low is the low balance level for triggering transfer Low fixedpoint.Value `json:"low"` // Middle is the middle balance level used for re-fill asset Middle fixedpoint.Value `json:"middle"` Verbose bool `json:"verbose"` State *State `persistence:"state"` } func (s *Strategy) ID() string { return ID } func (s *Strategy) CrossSubscribe(sessions map[string]*bbgo.ExchangeSession) {} func (s *Strategy) checkBalance(ctx context.Context, sessions map[string]*bbgo.ExchangeSession) { if s.Verbose { bbgo.Notify("📝 Checking %s low balance level exchange session...", s.Asset) } var total fixedpoint.Value for _, session := range sessions { if b, ok := session.GetAccount().Balance(s.Asset); ok { total = total.Add(b.Total()) } } lowLevelSession, lowLevelBalance, err := s.findLowBalanceLevelSession(sessions) if err != nil { bbgo.Notify("Can not find low balance level session: %s", err.Error()) log.WithError(err).Errorf("Can not find low balance level session") return } if lowLevelSession == nil { if s.Verbose { bbgo.Notify("✅ All %s balances are looking good, total value: %v", s.Asset, total) } return } bbgo.Notify("⚠️ Found low level %s balance from session %s: %v", s.Asset, lowLevelSession.Name, lowLevelBalance) middle := s.Middle if middle.IsZero() { middle = total.Div(fixedpoint.NewFromInt(int64(len(sessions)))).Mul(priceFixer) bbgo.Notify("Total value %v %s, setting middle to %v", total, s.Asset, middle) } requiredAmount := middle.Sub(lowLevelBalance.Available) bbgo.Notify("Need %v %s to satisfy the middle balance level %v", requiredAmount, s.Asset, middle) fromSession, _, err := s.findHighestBalanceLevelSession(sessions, requiredAmount) if err != nil || fromSession == nil { bbgo.Notify("Can not find session with enough balance") log.WithError(err).Errorf("can not find session with enough balance") return } withdrawalService, ok := fromSession.Exchange.(types.ExchangeWithdrawalService) if !ok { log.Errorf("exchange %s does not implement withdrawal service, we can not withdrawal", fromSession.ExchangeName) return } if !fromSession.Withdrawal { bbgo.Notify("The withdrawal function exchange session %s is not enabled", fromSession.Name) log.Errorf("The withdrawal function of exchange session %s is not enabled", fromSession.Name) return } toAddress, ok := s.Addresses[lowLevelSession.Name] if !ok { log.Errorf("%s address of session %s not found", s.Asset, lowLevelSession.Name) bbgo.Notify("%s address of session %s not found", s.Asset, lowLevelSession.Name) return } if toAddress.ForeignFee.Sign() > 0 { requiredAmount = requiredAmount.Add(toAddress.ForeignFee) } if s.State != nil { if s.MaxDailyNumberOfTransfer > 0 { if s.State.DailyNumberOfTransfers >= s.MaxDailyNumberOfTransfer { bbgo.Notify("⚠️ Exceeded %s max daily number of transfers %d (current %d), skipping transfer...", s.Asset, s.MaxDailyNumberOfTransfer, s.State.DailyNumberOfTransfers) return } } if s.MaxDailyAmountOfTransfer.Sign() > 0 { if s.State.DailyAmountOfTransfers.Compare(s.MaxDailyAmountOfTransfer) >= 0 { bbgo.Notify("⚠️ Exceeded %s max daily amount of transfers %v (current %v), skipping transfer...", s.Asset, s.MaxDailyAmountOfTransfer, s.State.DailyAmountOfTransfers) return } } } bbgo.Notify(&WithdrawalRequest{ FromSession: fromSession.Name, ToSession: lowLevelSession.Name, Asset: s.Asset, Amount: requiredAmount, }) if err := withdrawalService.Withdraw(ctx, s.Asset, requiredAmount, toAddress.Address, &types.WithdrawalOptions{ Network: toAddress.Network, AddressTag: toAddress.AddressTag, }); err != nil { log.WithError(err).Errorf("withdrawal failed") bbgo.Notify("withdrawal request failed, error: %v", err) return } bbgo.Notify("%s withdrawal request sent", s.Asset) if s.State != nil { if s.State.IsOver24Hours() { s.State.Reset() } s.State.DailyNumberOfTransfers += 1 s.State.DailyAmountOfTransfers = s.State.DailyAmountOfTransfers.Add(requiredAmount) bbgo.Sync(ctx, s) } } func (s *Strategy) findHighestBalanceLevelSession( sessions map[string]*bbgo.ExchangeSession, requiredAmount fixedpoint.Value, ) (*bbgo.ExchangeSession, types.Balance, error) { var balance types.Balance var maxBalanceLevel = fixedpoint.Zero var maxBalanceSession *bbgo.ExchangeSession = nil for sessionID := range s.Addresses { session, ok := sessions[sessionID] if !ok { return nil, balance, fmt.Errorf("session %s does not exist", sessionID) } if b, ok := session.GetAccount().Balance(s.Asset); ok { if b.Available.Sub(requiredAmount).Compare(s.Low) > 0 && b.Available.Compare(maxBalanceLevel) > 0 { maxBalanceLevel = b.Available maxBalanceSession = session balance = b } } } return maxBalanceSession, balance, nil } func (s *Strategy) findLowBalanceLevelSession(sessions map[string]*bbgo.ExchangeSession) (*bbgo.ExchangeSession, types.Balance, error) { var balance types.Balance for sessionID := range s.Addresses { session, ok := sessions[sessionID] if !ok { return nil, balance, fmt.Errorf("session %s does not exist", sessionID) } balance, ok = session.GetAccount().Balance(s.Asset) if ok { if balance.Available.Compare(s.Low) <= 0 { return session, balance, nil } } } return nil, balance, nil } func (s *Strategy) newDefaultState() *State { return &State{ Asset: s.Asset, DailyNumberOfTransfers: 0, DailyAmountOfTransfers: fixedpoint.Zero, } } func (s *Strategy) CrossRun(ctx context.Context, _ bbgo.OrderExecutionRouter, sessions map[string]*bbgo.ExchangeSession) error { if s.Interval == 0 { return errors.New("interval can not be zero") } if s.State == nil { s.State = s.newDefaultState() } bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() }) if s.CheckOnStart { s.checkBalance(ctx, sessions) } go func() { ticker := time.NewTicker(timejitter.Milliseconds(s.Interval.Duration(), 1000)) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: s.checkBalance(ctx, sessions) } } }() return nil }