mirror of
https://github.com/c9s/bbgo.git
synced 2024-11-22 14:55:16 +00:00
397 lines
11 KiB
Go
397 lines
11 KiB
Go
package deposit2transfer
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
"golang.org/x/time/rate"
|
|
|
|
"github.com/c9s/bbgo/pkg/bbgo"
|
|
"github.com/c9s/bbgo/pkg/exchange/retry"
|
|
"github.com/c9s/bbgo/pkg/fixedpoint"
|
|
"github.com/c9s/bbgo/pkg/livenote"
|
|
"github.com/c9s/bbgo/pkg/types"
|
|
)
|
|
|
|
type marginTransferService interface {
|
|
TransferMarginAccountAsset(ctx context.Context, asset string, amount fixedpoint.Value, io types.TransferDirection) error
|
|
}
|
|
|
|
type spotAccountQueryService interface {
|
|
QuerySpotAccount(ctx context.Context) (*types.Account, error)
|
|
}
|
|
|
|
const ID = "deposit2transfer"
|
|
|
|
var log = logrus.WithField("strategy", ID)
|
|
|
|
var errMarginTransferNotSupport = errors.New("exchange session does not support margin transfer")
|
|
|
|
var errDepositHistoryNotSupport = errors.New("exchange session does not support deposit history query")
|
|
|
|
func init() {
|
|
// Register the pointer of the strategy struct,
|
|
// so that bbgo knows what struct to be used to unmarshal the configs (YAML or JSON)
|
|
// Note: built-in strategies need to imported manually in the bbgo cmd package.
|
|
bbgo.RegisterStrategy(ID, &Strategy{})
|
|
}
|
|
|
|
type SlackAlert struct {
|
|
Channel string `json:"channel"`
|
|
Mentions []string `json:"mentions"`
|
|
Pin bool `json:"pin"`
|
|
}
|
|
|
|
type Strategy struct {
|
|
Environment *bbgo.Environment
|
|
|
|
Assets []string `json:"assets"`
|
|
|
|
Interval types.Duration `json:"interval"`
|
|
TransferDelay types.Duration `json:"transferDelay"`
|
|
|
|
IgnoreDust bool `json:"ignoreDust"`
|
|
DustAmounts map[string]fixedpoint.Value `json:"dustAmounts"`
|
|
|
|
SlackAlert *SlackAlert `json:"slackAlert"`
|
|
|
|
marginTransferService marginTransferService
|
|
depositHistoryService types.ExchangeTransferService
|
|
|
|
session *bbgo.ExchangeSession
|
|
|
|
watchingDeposits map[string]types.Deposit
|
|
mu sync.Mutex
|
|
|
|
logger logrus.FieldLogger
|
|
|
|
lastAssetDepositTimes map[string]time.Time
|
|
}
|
|
|
|
func (s *Strategy) ID() string {
|
|
return ID
|
|
}
|
|
|
|
func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) {}
|
|
|
|
func (s *Strategy) Defaults() error {
|
|
if s.Interval == 0 {
|
|
s.Interval = types.Duration(3 * time.Minute)
|
|
}
|
|
|
|
if s.TransferDelay == 0 {
|
|
s.TransferDelay = types.Duration(3 * time.Second)
|
|
}
|
|
|
|
if s.DustAmounts == nil {
|
|
s.DustAmounts = map[string]fixedpoint.Value{
|
|
"USDC": fixedpoint.NewFromFloat(1.0),
|
|
"USDT": fixedpoint.NewFromFloat(1.0),
|
|
"BTC": fixedpoint.NewFromFloat(0.00001),
|
|
"ETH": fixedpoint.NewFromFloat(0.00001),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) Initialize() error {
|
|
if s.logger == nil {
|
|
s.logger = log.Dup()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) Validate() error {
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) InstanceID() string {
|
|
return fmt.Sprintf("%s-%s", ID, s.Assets)
|
|
}
|
|
|
|
func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error {
|
|
s.session = session
|
|
s.watchingDeposits = make(map[string]types.Deposit)
|
|
s.lastAssetDepositTimes = make(map[string]time.Time)
|
|
s.logger = s.logger.WithField("exchange", session.ExchangeName)
|
|
|
|
var ok bool
|
|
|
|
s.marginTransferService, ok = session.Exchange.(marginTransferService)
|
|
if !ok {
|
|
return errMarginTransferNotSupport
|
|
}
|
|
|
|
s.depositHistoryService, ok = session.Exchange.(types.ExchangeTransferService)
|
|
if !ok {
|
|
return errDepositHistoryNotSupport
|
|
}
|
|
|
|
session.UserDataStream.OnStart(func() {
|
|
go s.tickWatcher(ctx, s.Interval.Duration())
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) isDust(asset string, amount fixedpoint.Value) bool {
|
|
if s.IgnoreDust {
|
|
if dustAmount, ok := s.DustAmounts[asset]; ok {
|
|
return amount.Compare(dustAmount) <= 0
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (s *Strategy) tickWatcher(ctx context.Context, interval time.Duration) {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
s.checkDeposits(ctx)
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
|
|
case <-ticker.C:
|
|
s.checkDeposits(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Strategy) checkDeposits(ctx context.Context) {
|
|
accountLimiter := rate.NewLimiter(rate.Every(5*time.Second), 1)
|
|
|
|
for _, asset := range s.Assets {
|
|
logger := s.logger.WithField("asset", asset)
|
|
|
|
logger.Debugf("checking %s deposits...", asset)
|
|
|
|
succeededDeposits, err := s.scanDepositHistory(ctx, asset, 4*time.Hour)
|
|
if err != nil {
|
|
logger.WithError(err).Errorf("unable to scan deposit history")
|
|
return
|
|
}
|
|
|
|
if len(succeededDeposits) == 0 {
|
|
logger.Debugf("no %s deposit found", asset)
|
|
continue
|
|
} else {
|
|
logger.Infof("found %d %s deposits", len(succeededDeposits), asset)
|
|
}
|
|
|
|
if s.TransferDelay > 0 {
|
|
logger.Infof("delaying transfer for %s...", s.TransferDelay.Duration())
|
|
time.Sleep(s.TransferDelay.Duration())
|
|
}
|
|
|
|
for _, d := range succeededDeposits {
|
|
logger.Infof("found succeeded %s deposit: %+v", asset, d)
|
|
|
|
if err2 := accountLimiter.Wait(ctx); err2 != nil {
|
|
logger.WithError(err2).Errorf("rate limiter error")
|
|
return
|
|
}
|
|
|
|
// we can't use the account from margin
|
|
amount := d.Amount
|
|
if service, ok := s.session.Exchange.(spotAccountQueryService); ok {
|
|
var account *types.Account
|
|
err = retry.GeneralBackoff(ctx, func() (err error) {
|
|
account, err = service.QuerySpotAccount(ctx)
|
|
return err
|
|
})
|
|
|
|
if err != nil || account == nil {
|
|
logger.WithError(err).Errorf("unable to query spot account")
|
|
continue
|
|
}
|
|
|
|
bal, ok := account.Balance(d.Asset)
|
|
if ok {
|
|
logger.Infof("spot account balance %s: %+v", d.Asset, bal)
|
|
amount = fixedpoint.Min(bal.Available, amount)
|
|
} else {
|
|
logger.Errorf("unexpected error: %s balance not found", d.Asset)
|
|
}
|
|
|
|
if amount.IsZero() || amount.Sign() < 0 {
|
|
bbgo.Notify("🔍 Found succeeded deposit %s %s, but the balance %s %s is insufficient, skip transferring",
|
|
d.Amount.String(), d.Asset,
|
|
bal.Available.String(), bal.Currency)
|
|
continue
|
|
}
|
|
}
|
|
|
|
bbgo.Notify("🔍 Found succeeded deposit %s %s, transferring %s %s into the margin account",
|
|
d.Amount.String(), d.Asset,
|
|
amount.String(), d.Asset)
|
|
|
|
if s.SlackAlert != nil {
|
|
bbgo.PostLiveNote(&d,
|
|
livenote.Channel(s.SlackAlert.Channel),
|
|
livenote.Comment(fmt.Sprintf("🚥 Transferring deposit asset %s %s into the margin account", amount.String(), d.Asset)),
|
|
)
|
|
}
|
|
|
|
err2 := retry.GeneralBackoff(ctx, func() error {
|
|
return s.marginTransferService.TransferMarginAccountAsset(ctx, d.Asset, amount, types.TransferIn)
|
|
})
|
|
|
|
if err2 != nil {
|
|
logger.WithError(err2).Errorf("unable to transfer deposit asset into the margin account")
|
|
|
|
if s.SlackAlert != nil {
|
|
bbgo.PostLiveNote(&d,
|
|
livenote.Channel(s.SlackAlert.Channel),
|
|
livenote.Comment(fmt.Sprintf("❌ Margin account transfer error: %+v", err2)),
|
|
)
|
|
}
|
|
} else {
|
|
if s.SlackAlert != nil {
|
|
bbgo.PostLiveNote(&d,
|
|
livenote.Channel(s.SlackAlert.Channel),
|
|
livenote.Comment(fmt.Sprintf("✅ %s %s transferred successfully", amount.String(), d.Asset)),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Strategy) addWatchingDeposit(deposit types.Deposit) {
|
|
s.watchingDeposits[deposit.TransactionID] = deposit
|
|
|
|
if lastTime, ok := s.lastAssetDepositTimes[deposit.Asset]; ok {
|
|
s.lastAssetDepositTimes[deposit.Asset] = later(deposit.Time.Time(), lastTime)
|
|
} else {
|
|
s.lastAssetDepositTimes[deposit.Asset] = deposit.Time.Time()
|
|
}
|
|
|
|
if s.SlackAlert != nil {
|
|
bbgo.PostLiveNote(&deposit,
|
|
livenote.Channel(s.SlackAlert.Channel),
|
|
livenote.Pin(s.SlackAlert.Pin),
|
|
livenote.CompareObject(true),
|
|
livenote.OneTimeMention(s.SlackAlert.Mentions...),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (s *Strategy) scanDepositHistory(ctx context.Context, asset string, duration time.Duration) ([]types.Deposit, error) {
|
|
logger := s.logger.WithField("asset", asset)
|
|
logger.Debugf("scanning %s deposit history...", asset)
|
|
|
|
now := time.Now()
|
|
since := now.Add(-duration)
|
|
|
|
var deposits []types.Deposit
|
|
err := retry.GeneralBackoff(ctx, func() (err error) {
|
|
deposits, err = s.depositHistoryService.QueryDepositHistory(ctx, asset, since, now)
|
|
return err
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// sort the recent deposit records in ascending order
|
|
sort.Slice(deposits, func(i, j int) bool {
|
|
return deposits[i].Time.Time().Before(deposits[j].Time.Time())
|
|
})
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// update the watching deposits
|
|
for _, deposit := range deposits {
|
|
logger.Debugf("checking deposit: %+v", deposit)
|
|
|
|
if deposit.Asset != asset {
|
|
continue
|
|
}
|
|
|
|
if s.isDust(asset, deposit.Amount) {
|
|
continue
|
|
}
|
|
|
|
// if the deposit record is already in the watch list, update it
|
|
if _, ok := s.watchingDeposits[deposit.TransactionID]; ok {
|
|
s.addWatchingDeposit(deposit)
|
|
} else {
|
|
// if the deposit record is not in the watch list, we need to check the status
|
|
// here the deposit is outside the watching list
|
|
switch deposit.Status {
|
|
|
|
case types.DepositSuccess:
|
|
// if the deposit is in success status, we need to check if it's newer than the latest deposit time
|
|
// this usually happens when the deposit is credited to the account very quickly
|
|
if depositTime, ok := s.lastAssetDepositTimes[asset]; ok {
|
|
// if it's newer than the latest deposit time, then we just add it the monitoring list
|
|
if deposit.Time.After(depositTime) {
|
|
logger.Infof("adding new succeedded deposit: %s", deposit.TransactionID)
|
|
s.addWatchingDeposit(deposit)
|
|
} else {
|
|
// ignore all initial deposits that are already in success status
|
|
logger.Infof("ignored expired succeedded deposit: %s %+v", deposit.TransactionID, deposit)
|
|
}
|
|
} else {
|
|
// if the latest deposit time is not found, check if the deposit is older than 5 minutes
|
|
expiryTime := 5 * time.Minute
|
|
if deposit.Time.Before(time.Now().Add(-expiryTime)) {
|
|
logger.Infof("ignored expired (%s) succeedded deposit: %s %+v", expiryTime, deposit.TransactionID, deposit)
|
|
} else {
|
|
s.addWatchingDeposit(deposit)
|
|
}
|
|
}
|
|
|
|
case types.DepositCredited, types.DepositPending:
|
|
logger.Infof("adding pending deposit: %s", deposit.TransactionID)
|
|
s.addWatchingDeposit(deposit)
|
|
}
|
|
}
|
|
}
|
|
|
|
var succeededDeposits []types.Deposit
|
|
|
|
// find and move out succeeded deposits
|
|
for _, deposit := range s.watchingDeposits {
|
|
switch deposit.Status {
|
|
case types.DepositSuccess:
|
|
logger.Infof("found pending -> success deposit: %+v", deposit)
|
|
current, required := deposit.GetCurrentConfirmation()
|
|
if deposit.UnlockConfirm > 0 {
|
|
if current < deposit.UnlockConfirm {
|
|
logger.Infof("deposit %s unlock confirm %d is not reached, current: %d, required: %d, skip this round", deposit.TransactionID, deposit.UnlockConfirm, current, required)
|
|
continue
|
|
}
|
|
} else if required > 0 && current < required {
|
|
logger.Infof("deposit %s confirm %d/%d is not reached", deposit.TransactionID, current, required)
|
|
continue
|
|
}
|
|
|
|
succeededDeposits = append(succeededDeposits, deposit)
|
|
delete(s.watchingDeposits, deposit.TransactionID)
|
|
}
|
|
}
|
|
|
|
return succeededDeposits, nil
|
|
}
|
|
|
|
func later(a, b time.Time) time.Time {
|
|
if a.After(b) {
|
|
return a
|
|
}
|
|
|
|
return b
|
|
}
|