diff --git a/go.mod b/go.mod index 63f509adc..e90c8e9f3 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ go 1.17 require ( github.com/DATA-DOG/go-sqlmock v1.5.0 - github.com/adshao/go-binance/v2 v2.3.3 + github.com/adshao/go-binance/v2 v2.3.5 github.com/c9s/requestgen v1.1.1-0.20211230171502-c042072e23cd github.com/c9s/rockhopper v1.2.1-0.20210217093258-2661955904a9 github.com/codingconcepts/env v0.0.0-20200821220118-a8fbf8d84482 @@ -16,7 +16,7 @@ require ( github.com/go-redis/redis/v8 v8.8.0 github.com/go-sql-driver/mysql v1.5.0 github.com/google/uuid v1.3.0 - github.com/gorilla/websocket v1.4.2 + github.com/gorilla/websocket v1.5.0 github.com/jmoiron/sqlx v1.3.4 github.com/joho/godotenv v1.3.0 github.com/leekchan/accounting v0.0.0-20191218023648-17a4ce5f94d4 diff --git a/go.sum b/go.sum index b5f52f531..6d8932327 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/adshao/go-binance/v2 v2.3.3 h1:ts46Mq4n4Ji9pkbXhcuOZ2T2Zy1x3i5SboC6EKKqzyg= github.com/adshao/go-binance/v2 v2.3.3/go.mod h1:TfcBwfGtmRibSljDDR0XCaPkfBt1kc2N9lnNMYC3dCQ= +github.com/adshao/go-binance/v2 v2.3.5 h1:WVYZecm0w8l14YoWlnKZj6xxZT2AKMTHpMQSqIX1xxA= +github.com/adshao/go-binance/v2 v2.3.5/go.mod h1:8Pg/FGTLyAhq8QXA0IkoReKyRpoxJcK3LVujKDAZV/c= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -229,6 +231,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= diff --git a/pkg/bbgo/session.go b/pkg/bbgo/session.go index bac3ea259..c06d78960 100644 --- a/pkg/bbgo/session.go +++ b/pkg/bbgo/session.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync" "time" "github.com/c9s/bbgo/pkg/cache" @@ -190,7 +191,8 @@ type ExchangeSession struct { // --------------------------- // The exchange account states - Account *types.Account `json:"-" yaml:"-"` + Account *types.Account `json:"-" yaml:"-"` + accountMutex sync.Mutex IsInitialized bool `json:"-" yaml:"-"` @@ -280,6 +282,18 @@ func NewExchangeSession(name string, exchange types.Exchange) *ExchangeSession { return session } +// UpdateAccount locks the account mutex and update the account object +func (session *ExchangeSession) UpdateAccount(ctx context.Context) error { + account, err := session.Exchange.QueryAccount(ctx) + if err != nil { + return err + } + session.accountMutex.Lock() + session.Account = account + session.accountMutex.Unlock() + return nil +} + // Init initializes the basic data structure and market information by its exchange. // Note that the subscribed symbols are not loaded in this stage. func (session *ExchangeSession) Init(ctx context.Context, environ *Environment) error { @@ -311,26 +325,39 @@ func (session *ExchangeSession) Init(ctx context.Context, environ *Environment) // query and initialize the balances if !session.PublicOnly { - log.Infof("querying balances from session %s...", session.Name) - balances, err := session.Exchange.QueryAccountBalances(ctx) + account, err := session.Exchange.QueryAccount(ctx) if err != nil { return err } + session.accountMutex.Lock() + session.Account = account + session.accountMutex.Unlock() + log.Infof("%s account", session.Name) - balances.Print() - session.Account.UpdateBalances(balances) + account.Balances().Print() // forward trade updates and order updates to the order executor session.UserDataStream.OnTradeUpdate(session.OrderExecutor.EmitTradeUpdate) session.UserDataStream.OnOrderUpdate(session.OrderExecutor.EmitOrderUpdate) - session.Account.BindStream(session.UserDataStream) + + session.UserDataStream.OnBalanceSnapshot(func(balances types.BalanceMap) { + session.accountMutex.Lock() + session.Account.UpdateBalances(balances) + session.accountMutex.Unlock() + }) + + session.UserDataStream.OnBalanceUpdate(func(balances types.BalanceMap) { + session.accountMutex.Lock() + session.Account.UpdateBalances(balances) + session.accountMutex.Unlock() + }) session.bindConnectionStatusNotification(session.UserDataStream, "user data") // if metrics mode is enabled, we bind the callbacks to update metrics if viper.GetBool("metrics") { - session.metricsBalancesUpdater(balances) + session.metricsBalancesUpdater(account.Balances()) session.bindUserDataStreamMetrics(session.UserDataStream) } } diff --git a/pkg/exchange/binance/exchange.go b/pkg/exchange/binance/exchange.go index 4fba04a28..c4e0e237a 100644 --- a/pkg/exchange/binance/exchange.go +++ b/pkg/exchange/binance/exchange.go @@ -227,62 +227,144 @@ func (e *Exchange) NewStream() types.Stream { return stream } -func (e *Exchange) QueryMarginAccount(ctx context.Context) (*types.Account, error) { - account, err := e.Client.NewGetMarginAccountService().Do(ctx) +func (e *Exchange) QueryMarginAssetMaxBorrowable(ctx context.Context, asset string) (amount fixedpoint.Value, err error) { + req := e.Client.NewGetMaxBorrowableService() + req.Asset(asset) + if e.IsIsolatedMargin { + req.IsolatedSymbol(e.IsolatedMarginSymbol) + } + resp, err := req.Do(ctx) + if err != nil { + return fixedpoint.Zero, err + } + + return fixedpoint.NewFromString(resp.Amount) +} + +func (e *Exchange) RepayMarginAsset(ctx context.Context, asset string, amount fixedpoint.Value) error { + req := e.Client.NewMarginRepayService() + req.Asset(asset) + req.Amount(amount.String()) + if e.IsIsolatedMargin { + req.IsolatedSymbol(e.IsolatedMarginSymbol) + } + + resp, err := req.Do(ctx) + log.Debugf("margin repayed %f %s, transaction id = %d", amount.Float64(), asset, resp.TranID) + return err +} + +func (e *Exchange) BorrowMarginAsset(ctx context.Context, asset string, amount fixedpoint.Value) error { + req := e.Client.NewMarginLoanService() + req.Asset(asset) + req.Amount(amount.String()) + if e.IsIsolatedMargin { + req.IsolatedSymbol(e.IsolatedMarginSymbol) + } + + resp, err := req.Do(ctx) + log.Debugf("margin borrowed %f %s, transaction id = %d", amount.Float64(), asset, resp.TranID) + return err +} + +// transferCrossMarginAccountAsset transfer asset to the cross margin account or to the main account +func (e *Exchange) transferCrossMarginAccountAsset(ctx context.Context, asset string, amount fixedpoint.Value, io int) error { + req := e.Client.NewMarginTransferService() + req.Asset(asset) + req.Amount(amount.String()) + + if io > 0 { // in + req.Type(binance.MarginTransferTypeToMargin) + } else if io < 0 { // out + req.Type(binance.MarginTransferTypeToMain) + } + resp, err := req.Do(ctx) + + log.Debugf("cross margin transfer %f %s, transaction id = %d", amount.Float64(), asset, resp.TranID) + return err +} + +func (e *Exchange) queryCrossMarginAccount(ctx context.Context) (*types.Account, error) { + marginAccount, err := e.Client.NewGetMarginAccountService().Do(ctx) if err != nil { return nil, err } + marginLevel := fixedpoint.MustNewFromString(marginAccount.MarginLevel) a := &types.Account{ - AccountType: types.AccountTypeMargin, - MarginInfo: toGlobalMarginAccountInfo(account), // In binance GO api, Account define account info which mantain []*AccountAsset and []*AccountPosition. + AccountType: types.AccountTypeMargin, + MarginInfo: toGlobalMarginAccountInfo(marginAccount), // In binance GO api, Account define marginAccount info which mantain []*AccountAsset and []*AccountPosition. + MarginLevel: marginLevel, + MarginTolerance: calculateMarginTolerance(marginLevel), + BorrowEnabled: marginAccount.BorrowEnabled, + TransferEnabled: marginAccount.TransferEnabled, } + // convert cross margin user assets into balances balances := types.BalanceMap{} - for _, userAsset := range account.UserAssets { + for _, userAsset := range marginAccount.UserAssets { balances[userAsset.Asset] = types.Balance{ Currency: userAsset.Asset, Available: fixedpoint.MustNewFromString(userAsset.Free), Locked: fixedpoint.MustNewFromString(userAsset.Locked), + Interest: fixedpoint.MustNewFromString(userAsset.Interest), + Borrowed: fixedpoint.MustNewFromString(userAsset.Borrowed), + NetAsset: fixedpoint.MustNewFromString(userAsset.NetAsset), } } a.UpdateBalances(balances) return a, nil } -func (e *Exchange) QueryIsolatedMarginAccount(ctx context.Context, symbols ...string) (*types.Account, error) { +func (e *Exchange) queryIsolatedMarginAccount(ctx context.Context) (*types.Account, error) { req := e.Client.NewGetIsolatedMarginAccountService() - if len(symbols) > 0 { - req.Symbols(symbols...) - } + req.Symbols(e.IsolatedMarginSymbol) - account, err := req.Do(ctx) + marginAccount, err := req.Do(ctx) if err != nil { return nil, err } a := &types.Account{ - AccountType: types.AccountTypeMargin, - IsolatedMarginInfo: toGlobalIsolatedMarginAccountInfo(account), // In binance GO api, Account define account info which mantain []*AccountAsset and []*AccountPosition. + AccountType: types.AccountTypeIsolatedMargin, + IsolatedMarginInfo: toGlobalIsolatedMarginAccountInfo(marginAccount), // In binance GO api, Account define marginAccount info which mantain []*AccountAsset and []*AccountPosition. } + // for isolated margin account, we will only have one asset in the Assets array. + if len(marginAccount.Assets) > 1 { + return nil, fmt.Errorf("unexpected number of user assets returned, got %d user assets", len(marginAccount.Assets)) + } + + userAsset := marginAccount.Assets[0] + marginLevel := fixedpoint.MustNewFromString(userAsset.MarginLevel) + a.MarginLevel = marginLevel + a.MarginTolerance = calculateMarginTolerance(marginLevel) + a.MarginRatio = fixedpoint.MustNewFromString(userAsset.MarginRatio) + a.BorrowEnabled = userAsset.BaseAsset.BorrowEnabled || userAsset.QuoteAsset.BorrowEnabled + a.LiquidationPrice = fixedpoint.MustNewFromString(userAsset.LiquidatePrice) + a.LiquidationRate = fixedpoint.MustNewFromString(userAsset.LiquidateRate) + + // Convert user assets into balances balances := types.BalanceMap{} - for _, userAsset := range account.Assets { - balances[userAsset.BaseAsset.Asset] = types.Balance{ - Currency: userAsset.BaseAsset.Asset, - Available: fixedpoint.MustNewFromString(userAsset.BaseAsset.Free), - Locked: fixedpoint.MustNewFromString(userAsset.BaseAsset.Locked), - } - - balances[userAsset.QuoteAsset.Asset] = types.Balance{ - Currency: userAsset.QuoteAsset.Asset, - Available: fixedpoint.MustNewFromString(userAsset.QuoteAsset.Free), - Locked: fixedpoint.MustNewFromString(userAsset.QuoteAsset.Locked), - } + balances[userAsset.BaseAsset.Asset] = types.Balance{ + Currency: userAsset.BaseAsset.Asset, + Available: fixedpoint.MustNewFromString(userAsset.BaseAsset.Free), + Locked: fixedpoint.MustNewFromString(userAsset.BaseAsset.Locked), + Interest: fixedpoint.MustNewFromString(userAsset.BaseAsset.Interest), + Borrowed: fixedpoint.MustNewFromString(userAsset.BaseAsset.Borrowed), + NetAsset: fixedpoint.MustNewFromString(userAsset.BaseAsset.NetAsset), } + + balances[userAsset.QuoteAsset.Asset] = types.Balance{ + Currency: userAsset.QuoteAsset.Asset, + Available: fixedpoint.MustNewFromString(userAsset.QuoteAsset.Free), + Locked: fixedpoint.MustNewFromString(userAsset.QuoteAsset.Locked), + Interest: fixedpoint.MustNewFromString(userAsset.QuoteAsset.Interest), + Borrowed: fixedpoint.MustNewFromString(userAsset.QuoteAsset.Borrowed), + NetAsset: fixedpoint.MustNewFromString(userAsset.QuoteAsset.NetAsset), + } + a.UpdateBalances(balances) - - return a, nil } @@ -541,9 +623,9 @@ func (e *Exchange) QueryAccount(ctx context.Context) (*types.Account, error) { if e.IsFutures { account, err = e.QueryFuturesAccount(ctx) } else if e.IsIsolatedMargin { - account, err = e.QueryIsolatedMarginAccount(ctx) + account, err = e.queryIsolatedMarginAccount(ctx) } else if e.IsMargin { - account, err = e.QueryMarginAccount(ctx) + account, err = e.queryCrossMarginAccount(ctx) } else { account, err = e.QuerySpotAccount(ctx) } @@ -1177,7 +1259,6 @@ func (e *Exchange) QueryKLines(ctx context.Context, symbol string, interval type } func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *types.TradeQueryOptions) (trades []types.Trade, err error) { - if e.IsMargin { var remoteTrades []*binance.TradeV3 req := e.Client.NewListMarginTradesService(). @@ -1217,6 +1298,8 @@ func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *type trades = append(trades, *localTrade) } + trades = types.SortTradesAscending(trades) + return trades, nil } else if e.IsFutures { var remoteTrades []*futures.AccountTrade @@ -1247,6 +1330,7 @@ func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *type trades = append(trades, *localTrade) } + trades = types.SortTradesAscending(trades) return trades, nil } else { var remoteTrades []*binance.TradeV3 @@ -1285,10 +1369,12 @@ func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *type trades = append(trades, *localTrade) } + trades = types.SortTradesAscending(trades) return trades, nil } } +// QueryDepth query the order book depth of a symbol func (e *Exchange) QueryDepth(ctx context.Context, symbol string) (snapshot types.SliceOrderBook, finalUpdateID int64, err error) { response, err := e.Client.NewDepthService().Symbol(symbol).Do(ctx) if err != nil { @@ -1415,3 +1501,17 @@ func getLaunchDate() (time.Time, error) { return time.Date(2017, time.July, 14, 0, 0, 0, 0, loc), nil } + +// Margin tolerance ranges from 0.0 (liquidation) to 1.0 (safest level of margin). +func calculateMarginTolerance(marginLevel fixedpoint.Value) fixedpoint.Value { + if marginLevel.IsZero() { + // Although margin level shouldn't be zero, that would indicate a significant problem. + // In that case, margin tolerance should return 0.0 to also reflect that problem. + return fixedpoint.Zero + } + + // Formula created by operations team for our binance code. Liquidation occurs at 1.1, + // so when marginLevel equals 1.1, the formula becomes 1.0 - 1.0, or zero. + // = 1.0 - (1.1 / marginLevel) + return fixedpoint.One.Sub(fixedpoint.NewFromFloat(1.1).Div(marginLevel)) +} diff --git a/pkg/types/account.go b/pkg/types/account.go index 42fffc4a9..e18131d1e 100644 --- a/pkg/types/account.go +++ b/pkg/types/account.go @@ -25,6 +25,13 @@ type Balance struct { Currency string `json:"currency"` Available fixedpoint.Value `json:"available"` Locked fixedpoint.Value `json:"locked,omitempty"` + + // margin related fields + Borrowed fixedpoint.Value `json:"borrowed,omitempty"` + Interest fixedpoint.Value `json:"interest,omitempty"` + + // NetAsset = (Available + Locked) - Borrowed - Interest + NetAsset fixedpoint.Value `json:"net,omitempty"` } func (b Balance) Total() fixedpoint.Value { @@ -223,9 +230,10 @@ func (m BalanceMap) Print() { type AccountType string const ( - AccountTypeFutures = AccountType("futures") - AccountTypeMargin = AccountType("margin") - AccountTypeSpot = AccountType("spot") + AccountTypeFutures = AccountType("futures") + AccountTypeMargin = AccountType("margin") + AccountTypeIsolatedMargin = AccountType("isolated_margin") + AccountTypeSpot = AccountType("spot") ) type Account struct { @@ -236,6 +244,23 @@ type Account struct { MarginInfo *MarginAccountInfo IsolatedMarginInfo *IsolatedMarginAccountInfo + // Margin related common field + // From binance: + // Margin Level = Total Asset Value / (Total Borrowed + Total Accrued Interest) + // If your margin level drops to 1.3, you will receive a Margin Call, which is a reminder that you should either increase your collateral (by depositing more funds) or reduce your loan (by repaying what you’ve borrowed). + // If your margin level drops to 1.1, your assets will be automatically liquidated, meaning that Binance will sell your funds at market price to repay the loan. + MarginLevel fixedpoint.Value `json:"marginLevel,omitempty"` + MarginTolerance fixedpoint.Value `json:"marginTolerance,omitempty"` + + BorrowEnabled bool `json:"borrowEnabled,omitempty"` + TransferEnabled bool `json:"transferEnabled,omitempty"` + + // isolated margin related fields + // LiquidationPrice is only used when account is in the isolated margin mode + MarginRatio fixedpoint.Value `json:"marginRatio,omitempty"` + LiquidationPrice fixedpoint.Value `json:"liquidationPrice,omitempty"` + LiquidationRate fixedpoint.Value `json:"liquidationRate,omitempty"` + MakerFeeRate fixedpoint.Value `json:"makerFeeRate,omitempty"` TakerFeeRate fixedpoint.Value `json:"takerFeeRate,omitempty"` @@ -393,18 +418,6 @@ func (a *Account) UpdateBalances(balances BalanceMap) { } } -func printBalanceUpdate(balances BalanceMap) { - logrus.Infof("balance update: %+v", balances) -} - -func (a *Account) BindStream(stream Stream) { - stream.OnBalanceUpdate(a.UpdateBalances) - stream.OnBalanceSnapshot(a.UpdateBalances) - if debugBalance { - stream.OnBalanceUpdate(printBalanceUpdate) - } -} - func (a *Account) Print() { a.Lock() defer a.Unlock() diff --git a/pkg/types/margin.go b/pkg/types/margin.go index 6931d168f..fdaeb2ee0 100644 --- a/pkg/types/margin.go +++ b/pkg/types/margin.go @@ -1,6 +1,10 @@ package types -import "github.com/c9s/bbgo/pkg/fixedpoint" +import ( + "context" + + "github.com/c9s/bbgo/pkg/fixedpoint" +) type FuturesExchange interface { UseFutures() @@ -48,6 +52,11 @@ type MarginExchange interface { // QueryMarginAccount(ctx context.Context) (*binance.MarginAccount, error) } +type MarginBorrowRepay interface { + RepayMarginAsset(ctx context.Context, asset string, amount fixedpoint.Value) error + BorrowMarginAsset(ctx context.Context, asset string, amount fixedpoint.Value) error +} + type MarginSettings struct { IsMargin bool IsIsolatedMargin bool