diff --git a/pkg/bbgo/account_value_calc.go b/pkg/bbgo/account_value_calc.go index 030ac30d1b..bc42779df1 100644 --- a/pkg/bbgo/account_value_calc.go +++ b/pkg/bbgo/account_value_calc.go @@ -62,7 +62,7 @@ func (c *AccountValueCalculator) UpdatePrices(ctx context.Context) error { } func (c *AccountValueCalculator) DebtValue() fixedpoint.Value { - balances := c.session.Account.Balances() + balances := c.session.Account.Balances().Debts() return totalValueInQuote(balances, c.priceSolver, c.quoteCurrency, func( prev fixedpoint.Value, b types.Balance, price fixedpoint.Value, ) fixedpoint.Value { @@ -100,9 +100,12 @@ func totalValueInQuote( for _, b := range balances { if b.Currency == quoteCurrency { totalValue = algo(totalValue, b, fixedpoint.One) - continue - } else if price, ok := priceSolver.ResolvePrice(b.Currency, quoteCurrency); ok { - totalValue = algo(totalValue, b, price) + } else { + if price, ok := priceSolver.ResolvePrice(b.Currency, quoteCurrency); ok { + totalValue = algo(totalValue, b, price) + } else { + log.Warnf("unable to solve price for %s/%s", b.Currency, quoteCurrency) + } } } diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index eae3085d17..ff8fe79338 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -687,6 +687,110 @@ func (s *Strategy) getLayerPrice( return price } +// margin level = totalValue / totalDebtValue +func calculateDebtQuota(totalValue, debtValue, minMarginLevel fixedpoint.Value) fixedpoint.Value { + if minMarginLevel.IsZero() || totalValue.IsZero() { + return fixedpoint.Zero + } + + debtCap := totalValue.Div(minMarginLevel) + debtQuota := debtCap.Sub(debtValue) + if debtQuota.Sign() < 0 { + return fixedpoint.Zero + } + + return debtQuota +} + +func (s *Strategy) allowMarginHedge(side types.SideType) (bool, fixedpoint.Value) { + zero := fixedpoint.Zero + + if !s.sourceSession.Margin { + return false, zero + } + + // GetAccount() is a lightweight operation, it doesn't make any API request + hedgeAccount := s.sourceSession.GetAccount() + lastPrice := s.lastPrice.Get() + + if hedgeAccount.MarginLevel.IsZero() || s.MinMarginLevel.IsZero() { + return false, zero + } + + marketValue := s.accountValueCalculator.MarketValue() + debtValue := s.accountValueCalculator.DebtValue() + + // if the margin level is higher than the minimal margin level, + // we can hedge the position, but we need to check the debt quota + if hedgeAccount.MarginLevel.Compare(s.MinMarginLevel) > 0 { + debtQuota := calculateDebtQuota(marketValue, debtValue, s.MinMarginLevel) + + if debtQuota.Sign() <= 0 { + return false, zero + } + + switch side { + case types.SideTypeBuy: + return true, debtQuota + + case types.SideTypeSell: + if lastPrice.IsZero() { + return false, zero + } + + return true, debtQuota.Div(lastPrice) + + } + return true, zero + } + + // side here is the side of maker + // if the margin level is too low, check if we can hedge the position with repayments to reduce the position + quoteBal, ok := hedgeAccount.Balance(s.sourceMarket.QuoteCurrency) + if !ok { + quoteBal = types.NewZeroBalance(s.sourceMarket.QuoteCurrency) + } + + baseBal, ok := hedgeAccount.Balance(s.sourceMarket.BaseCurrency) + if !ok { + baseBal = types.NewZeroBalance(s.sourceMarket.BaseCurrency) + } + + switch side { + case types.SideTypeBuy: + if baseBal.Available.IsZero() { + return false, zero + } + + quota := baseBal.Available.Mul(lastPrice) + + // for buy orders, we need to check if we can repay the quoteBal asset via selling the base balance + quoteDebt := quoteBal.Debt() + if quoteDebt.Sign() > 0 { + return true, fixedpoint.Min(quota, quoteDebt) + } + + return false, zero + + case types.SideTypeSell: + if quoteBal.Available.IsZero() { + return false, zero + } + + quota := quoteBal.Available.Div(lastPrice) + + baseDebt := baseBal.Debt() + if baseDebt.Sign() > 0 { + // return how much quote bal amount we can use to place the buy order + return true, fixedpoint.Min(quota, baseDebt) + } + + return false, zero + } + + return false, zero +} + func (s *Strategy) updateQuote(ctx context.Context) error { cancelMakerOrdersProfile := timeprofile.Start("cancelMakerOrders") @@ -844,58 +948,31 @@ func (s *Strategy) updateQuote(ctx context.Context) error { s.logger.Infof("hedge account margin level %s is less then the min margin level %s, calculating the borrowed positions", hedgeAccount.MarginLevel.String(), s.MinMarginLevel.String()) - - // TODO: should consider base asset debt as well. - if quote, ok := hedgeAccount.Balance(s.sourceMarket.QuoteCurrency); ok { - quoteDebt := quote.Debt() - if quoteDebt.Sign() > 0 { - hedgeQuota.BaseAsset.Add(quoteDebt.Div(bestBid.Price)) - } - } - - if base, ok := hedgeAccount.Balance(s.sourceMarket.BaseCurrency); ok { - baseDebt := base.Debt() - if baseDebt.Sign() > 0 { - hedgeQuota.QuoteAsset.Add(baseDebt.Mul(bestAsk.Price)) - } - } } else { s.logger.Infof("hedge account margin level %s is greater than the min margin level %s, calculating the net value", hedgeAccount.MarginLevel.String(), s.MinMarginLevel.String()) + } - netValueInUsd := s.accountValueCalculator.NetValue() - - // calculate credit buffer - s.logger.Infof("hedge account net value in usd: %f", netValueInUsd.Float64()) - - maximumValueInUsd := netValueInUsd.Mul(s.MaxHedgeAccountLeverage) - - s.logger.Infof("hedge account maximum leveraged value in usd: %f (%f x)", maximumValueInUsd.Float64(), s.MaxHedgeAccountLeverage.Float64()) - - if quote, ok := hedgeAccount.Balance(s.sourceMarket.QuoteCurrency); ok { - debt := quote.Debt() - quota := maximumValueInUsd.Sub(debt) - - s.logger.Infof("hedge account quote balance: %s, debt: %s, quota: %s", - quote.String(), - debt.String(), - quota.String()) - - hedgeQuota.QuoteAsset.Add(quota) - } + // calculate credit buffer + netValueInUsd := s.accountValueCalculator.NetValue() + s.logger.Infof("hedge account net value in usd: %f", netValueInUsd.Float64()) - if base, ok := hedgeAccount.Balance(s.sourceMarket.BaseCurrency); ok { - debt := base.Debt() - quota := maximumValueInUsd.Div(bestAsk.Price).Sub(debt) + maximumValueInUsd := netValueInUsd.Mul(s.MaxHedgeAccountLeverage) + s.logger.Infof("hedge account maximum leveraged value in usd: %f (%f x)", maximumValueInUsd.Float64(), s.MaxHedgeAccountLeverage.Float64()) - s.logger.Infof("hedge account base balance: %s, debt: %s, quota: %s", - base.String(), - debt.String(), - quota.String()) + allowMarginBuy, bidQuota := s.allowMarginHedge(types.SideTypeBuy) + if allowMarginBuy { + hedgeQuota.BaseAsset.Add(bidQuota.Div(bestBid.Price)) + } else { + disableMakerBid = true + } - hedgeQuota.BaseAsset.Add(quota) - } + allowMarginSell, sellQuota := s.allowMarginHedge(types.SideTypeSell) + if allowMarginSell { + hedgeQuota.QuoteAsset.Add(sellQuota.Mul(bestAsk.Price)) + } else { + disableMakerAsk = true } } else { if b, ok := hedgeBalances[s.sourceMarket.BaseCurrency]; ok { @@ -989,7 +1066,7 @@ func (s *Strategy) updateQuote(ctx context.Context) error { askMarginMetrics.With(s.metricsLabels).Set(quote.AskMargin.Float64()) if s.EnableArbitrage { - done, err := s.tryArbitrage(ctx, quote, makerBalances, hedgeBalances) + done, err := s.tryArbitrage(ctx, quote, makerBalances, hedgeBalances, disableMakerBid, disableMakerAsk) if err != nil { s.logger.WithError(err).Errorf("unable to arbitrage") } else if done { @@ -1184,7 +1261,9 @@ func aggregatePriceVolumeSliceWithPriceFilter( } // tryArbitrage tries to arbitrage between the source and maker exchange -func (s *Strategy) tryArbitrage(ctx context.Context, quote *Quote, makerBalances, hedgeBalances types.BalanceMap) (bool, error) { +func (s *Strategy) tryArbitrage( + ctx context.Context, quote *Quote, makerBalances, hedgeBalances types.BalanceMap, disableBid, disableAsk bool, +) (bool, error) { if s.makerBook == nil { return false, nil } @@ -1200,7 +1279,7 @@ func (s *Strategy) tryArbitrage(ctx context.Context, quote *Quote, makerBalances var iocOrders []types.SubmitOrder if makerAsk.Price.Compare(marginBidPrice) <= 0 { quoteBalance, hasQuote := makerBalances[s.makerMarket.QuoteCurrency] - if !hasQuote { + if !hasQuote || disableBid { return false, nil } @@ -1233,7 +1312,7 @@ func (s *Strategy) tryArbitrage(ctx context.Context, quote *Quote, makerBalances } else if makerBid.Price.Compare(marginAskPrice) >= 0 { baseBalance, hasBase := makerBalances[s.makerMarket.BaseCurrency] - if !hasBase { + if !hasBase || disableAsk { return false, nil } @@ -1612,6 +1691,10 @@ func (s *Strategy) Defaults() error { } } + if s.MinMarginLevel.IsZero() { + s.MinMarginLevel = fixedpoint.NewFromFloat(2.0) + } + // circuitBreakerAlertLimiter is for CircuitBreaker alerts s.circuitBreakerAlertLimiter = rate.NewLimiter(rate.Every(3*time.Minute), 2) s.reportProfitStatsRateLimiter = rate.NewLimiter(rate.Every(3*time.Minute), 1) diff --git a/pkg/strategy/xmaker/strategy_test.go b/pkg/strategy/xmaker/strategy_test.go index 82183ccf06..7688e08727 100644 --- a/pkg/strategy/xmaker/strategy_test.go +++ b/pkg/strategy/xmaker/strategy_test.go @@ -1,3 +1,6 @@ +//go:build !dnum +// +build !dnum + package xmaker import ( @@ -6,12 +9,120 @@ import ( "github.com/stretchr/testify/assert" + "github.com/c9s/bbgo/pkg/bbgo" "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/pricesolver" "github.com/c9s/bbgo/pkg/types" . "github.com/c9s/bbgo/pkg/testing/testhelper" ) +func TestStrategy_allowMarginHedge(t *testing.T) { + symbol := "BTCUSDT" + market := Market(symbol) + priceSolver := pricesolver.NewSimplePriceResolver(AllMarkets()) + priceSolver.Update("BTCUSDT", Number(98000.0)) + priceSolver.Update("ETHUSDT", Number(3800.0)) + + // total equity value = 2 BTC at 98,000 + 200,000.0 USDT = 196,000 USDT + 200,000.0 USDT = 396,000 USDT + // net equity value = 1 BTC at 98,000 + 200,000.0 USDT = 98,000 USDT + 200,000.0 USDT = 298,000 USDT + // debt value = 1 BTC at 98,000 = 98,000 USDT + // current margin level = total equity value / debt value = 396,000 / 98,000 = 4.04081632653 + // borrowing quota = (total equity value / min margin level 1.7) - debt value = (396,000 / 1.7) - 98,000 = 232,941.176470588 + t.Run("safe margin level, calculate borrowing quota", func(t *testing.T) { + account := types.NewAccount() + account.MarginLevel = Number(3.04081632) + account.SetBalance("BTC", types.Balance{ + Currency: "BTC", + Available: Number(2.0), + Borrowed: Number(1.0), + }) + + account.SetBalance("USDT", types.Balance{ + Currency: "USDT", + Available: Number(200_000.0), + }) + + session := &bbgo.ExchangeSession{ + Margin: true, + Account: account, + } + + accountValueCalc := bbgo.NewAccountValueCalculator(session, priceSolver, types.USDT) + assert.Equal(t, "98000", accountValueCalc.DebtValue().String()) + assert.Equal(t, "298000", accountValueCalc.NetValue().String()) + + s := &Strategy{ + MinMarginLevel: Number(1.7), + makerMarket: market, + sourceMarket: market, + sourceSession: session, + accountValueCalculator: accountValueCalc, + } + s.lastPrice.Set(Number(98000.0)) + + allowed, quota := s.allowMarginHedge(types.SideTypeBuy) + if assert.True(t, allowed) { + assert.InDelta(t, 134941.176470588, quota.Float64(), 1.0, "should be able to borrow %f USDT", quota.Float64()) + } + + allowed, quota = s.allowMarginHedge(types.SideTypeSell) + if assert.True(t, allowed) { + assert.InDelta(t, 1.376951, quota.Float64(), 0.0001, "should be able to borrow %f BTC", quota.Float64()) + } + }) + + // total equity value = 2 BTC at 98,000 + 200,000.0 USDT = 196,000 USDT + 200,000.0 USDT = 396,000 USDT + // net equity value = -2 BTC at 98,000 + 200,000.0 USDT = -196,000 USDT + 200,000.0 USDT = 4,000 USDT + // debt value = 4 BTC at 98,000 = 392,000 USDT + // current margin level = total equity value / debt value = 396,000 / 392,000 = 1.01020408163 + t.Run("low margin level, calculate quota", func(t *testing.T) { + account := types.NewAccount() + account.SetBalance("BTC", types.Balance{ + Currency: "BTC", + Available: Number(2.0), + Borrowed: Number(4.0), + }) + + account.SetBalance("USDT", types.Balance{ + Currency: "USDT", + Available: Number(200_000.0), + }) + + session := &bbgo.ExchangeSession{ + Margin: true, + Account: account, + } + + accountValueCalc := bbgo.NewAccountValueCalculator(session, priceSolver, types.USDT) + assert.Equal(t, "392000", accountValueCalc.DebtValue().String()) + assert.Equal(t, "4000", accountValueCalc.NetValue().String()) + + var err error + account.MarginLevel, err = accountValueCalc.MarginLevel() + if assert.NoError(t, err) { + assert.InDelta(t, 1.01, account.MarginLevel.Float64(), 0.001) + } + + s := &Strategy{ + MinMarginLevel: Number(1.7), + makerMarket: market, + sourceMarket: market, + sourceSession: session, + accountValueCalculator: accountValueCalc, + } + s.lastPrice.Set(Number(98000.0)) + + allowed, quota := s.allowMarginHedge(types.SideTypeBuy) + assert.False(t, allowed) + + allowed, quota = s.allowMarginHedge(types.SideTypeSell) + if assert.True(t, allowed) { + assert.InDelta(t, 2.04, quota.Float64(), 0.001, "should be able to borrow %f BTC", quota.Float64()) + } + }) +} + func TestStrategy_getLayerPrice(t *testing.T) { symbol := "BTCUSDT" market := Market(symbol) diff --git a/pkg/types/account.go b/pkg/types/account.go index 5cb4ca92de..c681924ca4 100644 --- a/pkg/types/account.go +++ b/pkg/types/account.go @@ -140,6 +140,14 @@ func (a *Account) Balance(currency string) (balance Balance, ok bool) { return balance, ok } +func (a *Account) SetBalance(currency string, bal Balance) { + a.Lock() + defer a.Unlock() + + bal.Currency = currency + a.balances[currency] = bal +} + func (a *Account) AddBalance(currency string, fund fixedpoint.Value) { a.Lock() defer a.Unlock()