Merge pull request #555 from c9s/feature/binance-margin-api

feature: binance margin api integration
This commit is contained in:
Yo-An Lin 2022-04-23 14:58:43 +08:00 committed by GitHub
commit c3cc34d770
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 207 additions and 54 deletions

4
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

View File

@ -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)
}
}

View File

@ -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))
}

View File

@ -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 youve 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()

View File

@ -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