Huobi: Implement ticker batching (#1439)

* Add huobi ticker batching and tests

* sneaky funding rate fix, don't look

* better processing, better tests

* inline structs per shazbaz

* formatting expansion

* weird

* introduce time param, mini fixes

* linter splinter

* merge fix, kraken fix

* move comment, use require, add len
This commit is contained in:
Scott
2024-01-22 15:19:47 +11:00
committed by GitHub
parent 45d65c4906
commit d907aab576
6 changed files with 349 additions and 99 deletions

View File

@@ -28,44 +28,47 @@ const (
huobiAPIVersion2 = "2"
// Spot endpoints
huobiMarketHistoryKline = "/market/history/kline"
huobiMarketDetail = "/market/detail"
huobiMarketDetailMerged = "/market/detail/merged"
huobi24HrMarketSummary = "/market/detail?"
huobiMarketDepth = "/market/depth"
huobiMarketTrade = "/market/trade"
huobiMarketTickers = "/market/tickers"
huobiMarketTradeHistory = "/market/history/trade"
huobiSymbols = "/v1/common/symbols"
huobiCurrencies = "/v1/common/currencys"
huobiTimestamp = "/common/timestamp"
huobiAccounts = "/account/accounts"
huobiAccountBalance = "/account/accounts/%s/balance"
huobiAccountDepositAddress = "/account/deposit/address"
huobiAccountWithdrawQuota = "/account/withdraw/quota"
huobiAccountQueryWithdrawAddress = "/account/withdraw/"
huobiAggregatedBalance = "/subuser/aggregate-balance"
huobiOrderPlace = "/order/orders/place"
huobiOrderCancel = "/order/orders/%s/submitcancel"
huobiOrderCancelBatch = "/order/orders/batchcancel"
huobiBatchCancelOpenOrders = "/order/orders/batchCancelOpenOrders"
huobiGetOrder = "/order/orders/getClientOrder"
huobiGetOrderMatch = "/order/orders/%s/matchresults"
huobiGetOrders = "/order/orders"
huobiGetOpenOrders = "/order/openOrders"
huobiGetOrdersMatch = "/orders/matchresults"
huobiMarginTransferIn = "/dw/transfer-in/margin"
huobiMarginTransferOut = "/dw/transfer-out/margin"
huobiMarginOrders = "/margin/orders"
huobiMarginRepay = "/margin/orders/%s/repay"
huobiMarginLoanOrders = "/margin/loan-orders"
huobiMarginAccountBalance = "/margin/accounts/balance"
huobiWithdrawCreate = "/dw/withdraw/api/create"
huobiWithdrawCancel = "/dw/withdraw-virtual/%s/cancel"
huobiStatusError = "error"
huobiMarginRates = "/margin/loan-info"
huobiCurrenciesReference = "/v2/reference/currencies"
huobiWithdrawHistory = "/query/deposit-withdraw"
huobiMarketHistoryKline = "/market/history/kline"
huobiMarketDetail = "/market/detail"
huobiMarketDetailMerged = "/market/detail/merged"
huobi24HrMarketSummary = "/market/detail?"
huobiMarketDepth = "/market/depth"
huobiMarketTrade = "/market/trade"
huobiMarketTickers = "/market/tickers"
huobiMarketTradeHistory = "/market/history/trade"
huobiSymbols = "/v1/common/symbols"
huobiCurrencies = "/v1/common/currencys"
huobiTimestamp = "/common/timestamp"
huobiAccounts = "/account/accounts"
huobiAccountBalance = "/account/accounts/%s/balance"
huobiAccountDepositAddress = "/account/deposit/address"
huobiAccountWithdrawQuota = "/account/withdraw/quota"
huobiAccountQueryWithdrawAddress = "/account/withdraw/"
huobiAggregatedBalance = "/subuser/aggregate-balance"
huobiOrderPlace = "/order/orders/place"
huobiOrderCancel = "/order/orders/%s/submitcancel"
huobiOrderCancelBatch = "/order/orders/batchcancel"
huobiBatchCancelOpenOrders = "/order/orders/batchCancelOpenOrders"
huobiGetOrder = "/order/orders/getClientOrder"
huobiGetOrderMatch = "/order/orders/%s/matchresults"
huobiGetOrders = "/order/orders"
huobiGetOpenOrders = "/order/openOrders"
huobiGetOrdersMatch = "/orders/matchresults"
huobiMarginTransferIn = "/dw/transfer-in/margin"
huobiMarginTransferOut = "/dw/transfer-out/margin"
huobiMarginOrders = "/margin/orders"
huobiMarginRepay = "/margin/orders/%s/repay"
huobiMarginLoanOrders = "/margin/loan-orders"
huobiMarginAccountBalance = "/margin/accounts/balance"
huobiWithdrawCreate = "/dw/withdraw/api/create"
huobiWithdrawCancel = "/dw/withdraw-virtual/%s/cancel"
huobiStatusError = "error"
huobiMarginRates = "/margin/loan-info"
huobiCurrenciesReference = "/v2/reference/currencies"
huobiWithdrawHistory = "/query/deposit-withdraw"
huobiBatchCoinMarginSwapContracts = "/v2/swap-ex/market/detail/batch_merged"
huobiBatchLinearSwapContracts = "/linear-swap-ex/market/detail/batch_merged"
huobiBatchContracts = "/v2/market/detail/batch_merged"
)
// HUOBI is the overarching type across this package
@@ -129,6 +132,33 @@ func (h *HUOBI) Get24HrMarketSummary(ctx context.Context, symbol currency.Pair)
return result, h.SendHTTPRequest(ctx, exchange.RestSpot, huobi24HrMarketSummary+params.Encode(), &result)
}
// GetBatchCoinMarginSwapContracts returns the tickers for coin margined swap contracts
func (h *HUOBI) GetBatchCoinMarginSwapContracts(ctx context.Context) ([]FuturesBatchTicker, error) {
var result struct {
Data []FuturesBatchTicker `json:"ticks"`
}
err := h.SendHTTPRequest(ctx, exchange.RestFutures, huobiBatchCoinMarginSwapContracts, &result)
return result.Data, err
}
// GetBatchLinearSwapContracts returns the tickers for linear swap contracts
func (h *HUOBI) GetBatchLinearSwapContracts(ctx context.Context) ([]FuturesBatchTicker, error) {
var result struct {
Data []FuturesBatchTicker `json:"ticks"`
}
err := h.SendHTTPRequest(ctx, exchange.RestFutures, huobiBatchLinearSwapContracts, &result)
return result.Data, err
}
// GetBatchFuturesContracts returns the tickers for futures contracts
func (h *HUOBI) GetBatchFuturesContracts(ctx context.Context) ([]FuturesBatchTicker, error) {
var result struct {
Data []FuturesBatchTicker `json:"ticks"`
}
err := h.SendHTTPRequest(ctx, exchange.RestFutures, huobiBatchContracts, &result)
return result.Data, err
}
// GetTickers returns the ticker for the specified symbol
func (h *HUOBI) GetTickers(ctx context.Context) (Tickers, error) {
var result Tickers

View File

@@ -81,6 +81,8 @@ const (
fContractDateFormat = "060102"
)
var errInvalidContractType = errors.New("invalid contract type")
// FGetContractInfo gets contract info for futures
func (h *HUOBI) FGetContractInfo(ctx context.Context, symbol, contractType string, code currency.Pair) (FContractInfoData, error) {
var resp FContractInfoData
@@ -191,11 +193,11 @@ func (h *HUOBI) FContractOpenInterest(ctx context.Context, symbol, contractType
params.Set("contract_type", contractType)
}
if !code.IsEmpty() {
codeValue, err := h.convertContractShortHandToExpiry(code)
codeValue, err := h.formatFuturesPair(code, true)
if err != nil {
return resp, err
}
params.Set("contract_code", codeValue.String())
params.Set("contract_code", codeValue)
}
path := common.EncodeURLValues(fContractOpenInterest, params)
return resp, h.SendHTTPRequest(ctx, exchange.RestFutures, path, &resp)
@@ -374,7 +376,7 @@ func (h *HUOBI) FQueryHisOpenInterest(ctx context.Context, symbol, contractType,
params.Set("symbol", symbol)
}
if !common.StringDataCompareInsensitive(validContractTypes, contractType) {
return resp, fmt.Errorf("invalid contract type")
return resp, fmt.Errorf("%w %v", errInvalidContractType, contractType)
}
params.Set("contract_type", contractType)
if !common.StringDataCompareInsensitive(validPeriods, period) {
@@ -1256,7 +1258,7 @@ func (h *HUOBI) formatFuturesCode(p currency.Code) (string, error) {
func (h *HUOBI) formatFuturesPair(p currency.Pair, convertQuoteToExpiry bool) (string, error) {
if common.StringDataCompareInsensitive(validContractShortTypes, p.Quote.String()) {
if convertQuoteToExpiry {
cp, err := h.convertContractShortHandToExpiry(p)
cp, err := h.convertContractShortHandToExpiry(p, time.Now())
if err != nil {
return "", err
}
@@ -1273,12 +1275,12 @@ func (h *HUOBI) formatFuturesPair(p currency.Pair, convertQuoteToExpiry bool) (s
// convertContractShortHandToExpiry converts a contract shorthand eg BTC-CW into a full expiry date
// eg BTC240329 to associate with tradable pair formatting
func (h *HUOBI) convertContractShortHandToExpiry(pair currency.Pair) (currency.Pair, error) {
if !common.StringDataCompareInsensitive(validContractShortTypes, pair.Quote.String()) {
return currency.EMPTYPAIR, fmt.Errorf("%s invalid contract type", pair)
func (h *HUOBI) convertContractShortHandToExpiry(pair currency.Pair, tt time.Time) (currency.Pair, error) {
loc, err := time.LoadLocation("Asia/Singapore")
if err != nil {
return currency.EMPTYPAIR, err
}
tt := time.Now()
tt = tt.In(loc)
switch pair.Quote.Item.Symbol {
case "NW":
tt = tt.AddDate(0, 0, 7)
@@ -1304,6 +1306,8 @@ func (h *HUOBI) convertContractShortHandToExpiry(pair currency.Pair) (currency.P
for tt.Weekday() != time.Friday {
tt = tt.AddDate(0, 0, -1)
}
default:
return currency.EMPTYPAIR, fmt.Errorf(" %w %v", errInvalidContractType, pair)
}
pair.Quote = currency.NewCode(tt.Format(fContractDateFormat))
return pair, nil

View File

@@ -13,6 +13,7 @@ import (
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/common/key"
"github.com/thrasher-corp/gocryptotrader/config"
@@ -26,6 +27,7 @@ import (
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
"github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues"
"github.com/thrasher-corp/gocryptotrader/exchanges/stream"
"github.com/thrasher-corp/gocryptotrader/exchanges/ticker"
"github.com/thrasher-corp/gocryptotrader/portfolio/withdraw"
)
@@ -2686,7 +2688,6 @@ func TestGetAvailableTransferChains(t *testing.T) {
t.Error("expected more than one result")
}
}
func TestFormatFuturesPair(t *testing.T) {
r, err := h.formatFuturesPair(futuresTestPair, false)
if err != nil {
@@ -2858,6 +2859,101 @@ func TestGetSwapFundingRates(t *testing.T) {
}
}
func TestGetBatchCoinMarginSwapContracts(t *testing.T) {
t.Parallel()
resp, err := h.GetBatchCoinMarginSwapContracts(context.Background())
assert.NoError(t, err)
assert.NotEmpty(t, resp)
}
func TestGetBatchLinearSwapContracts(t *testing.T) {
t.Parallel()
resp, err := h.GetBatchLinearSwapContracts(context.Background())
assert.NoError(t, err)
assert.NotEmpty(t, resp)
}
func TestGetBatchFuturesContracts(t *testing.T) {
t.Parallel()
resp, err := h.GetBatchFuturesContracts(context.Background())
assert.NoError(t, err)
assert.NotEmpty(t, resp)
}
func TestUpdateTickers(t *testing.T) {
t.Parallel()
for _, a := range h.GetAssetTypes(false) {
err := h.UpdateTickers(context.Background(), a)
assert.NoErrorf(t, err, "asset %s", a)
avail, err := h.GetAvailablePairs(a)
require.NoError(t, err)
for x := range avail {
_, err = ticker.GetTicker(h.Name, avail[x], a)
assert.NoError(t, err)
}
}
}
func TestConvertContractShortHandToExpiry(t *testing.T) {
t.Parallel()
tt := time.Now()
cp := currency.NewPair(currency.BTC, currency.NewCode("CW"))
cp, err := h.convertContractShortHandToExpiry(cp, tt)
assert.NoError(t, err)
assert.NotEqual(t, cp.Quote.String(), "CW")
tick, err := h.FetchTicker(context.Background(), cp, asset.Futures)
if assert.NoError(t, err) {
assert.NotZero(t, tick.Close)
}
cp = currency.NewPair(currency.BTC, currency.NewCode("NW"))
cp, err = h.convertContractShortHandToExpiry(cp, tt)
assert.NoError(t, err)
assert.NotEqual(t, cp.Quote.String(), "NW")
tick, err = h.FetchTicker(context.Background(), cp, asset.Futures)
if assert.NoError(t, err) {
assert.NotZero(t, tick.Close)
}
cp = currency.NewPair(currency.BTC, currency.NewCode("CQ"))
cp, err = h.convertContractShortHandToExpiry(cp, tt)
assert.NoError(t, err)
assert.NotEqual(t, cp.Quote.String(), "CQ")
tick, err = h.FetchTicker(context.Background(), cp, asset.Futures)
if assert.NoError(t, err) {
assert.NotZero(t, tick.Close)
}
// calculate a specific date
cp = currency.NewPair(currency.BTC, currency.NewCode("CQ"))
tt = time.Date(2021, 6, 3, 0, 0, 0, 0, time.UTC)
cp, err = h.convertContractShortHandToExpiry(cp, tt)
assert.NoError(t, err)
assert.Equal(t, cp.Quote.String(), "210625")
cp = currency.NewPair(currency.BTC, currency.NewCode("CW"))
cp, err = h.convertContractShortHandToExpiry(cp, tt)
assert.NoError(t, err)
assert.Equal(t, cp.Quote.String(), "210604")
cp = currency.NewPair(currency.BTC, currency.NewCode("CWif hat"))
_, err = h.convertContractShortHandToExpiry(cp, tt)
assert.ErrorIs(t, err, errInvalidContractType)
tt = time.Now()
cp = currency.NewPair(currency.BTC, currency.NewCode("NQ"))
cp, err = h.convertContractShortHandToExpiry(cp, tt)
assert.NoError(t, err)
assert.NotEqual(t, cp.Quote.String(), "NQ")
tick, err = h.FetchTicker(context.Background(), cp, asset.Futures)
if err != nil {
// Huobi doesn't always have a next-quarter contract, return if no data found
return
}
assert.NotZero(t, tick.Close)
}
func TestGetOpenInterest(t *testing.T) {
t.Parallel()
_, err := h.GetOpenInterest(context.Background(), key.PairAsset{
@@ -2911,41 +3007,3 @@ func TestContractOpenInterestUSDT(t *testing.T) {
assert.NoError(t, err)
assert.NotEmpty(t, resp)
}
func TestConvertContractShortHandToExpiry(t *testing.T) {
t.Parallel()
cp := currency.NewPair(currency.BTC, currency.NewCode("CW"))
cp, err := h.convertContractShortHandToExpiry(cp)
assert.NoError(t, err)
assert.NotEqual(t, cp.Quote.String(), "CW")
tick, err := h.FetchTicker(context.Background(), cp, asset.Futures)
assert.NoError(t, err)
assert.NotZero(t, tick.Close)
cp = currency.NewPair(currency.BTC, currency.NewCode("NW"))
cp, err = h.convertContractShortHandToExpiry(cp)
assert.NoError(t, err)
assert.NotEqual(t, cp.Quote.String(), "NW")
tick, err = h.FetchTicker(context.Background(), cp, asset.Futures)
assert.NoError(t, err)
assert.NotZero(t, tick.Close)
cp = currency.NewPair(currency.BTC, currency.NewCode("CQ"))
cp, err = h.convertContractShortHandToExpiry(cp)
assert.NoError(t, err)
assert.NotEqual(t, cp.Quote.String(), "CQ")
tick, err = h.FetchTicker(context.Background(), cp, asset.Futures)
assert.NoError(t, err)
assert.NotZero(t, tick.Close)
cp = currency.NewPair(currency.BTC, currency.NewCode("NQ"))
cp, err = h.convertContractShortHandToExpiry(cp)
assert.NoError(t, err)
assert.NotEqual(t, cp.Quote.String(), "NQ")
tick, err = h.FetchTicker(context.Background(), cp, asset.Futures)
if err != nil {
// Huobi doesn't always have a next-quarter contract
return
}
assert.NotZero(t, tick.Close)
}

View File

@@ -3,6 +3,7 @@ package huobi
import (
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
"github.com/thrasher-corp/gocryptotrader/types"
)
type errorCapture struct {
@@ -520,16 +521,40 @@ type Tickers struct {
Data []Ticker `json:"data"`
}
// FuturesBatchTicker holds ticker data
type FuturesBatchTicker struct {
ID float64 `json:"id"`
Timestamp int64 `json:"ts"`
Ask [2]float64 `json:"ask"`
Bid [2]float64 `json:"bid"`
BusinessType string `json:"business_type"`
ContractCode string `json:"contract_code"`
Open types.Number `json:"open"`
Close types.Number `json:"close"`
Low types.Number `json:"low"`
High types.Number `json:"high"`
Amount types.Number `json:"amount"`
Count float64 `json:"count"`
Volume types.Number `json:"vol"`
TradeTurnover types.Number `json:"trade_turnover"`
TradePartition string `json:"trade_partition"`
Symbol string `json:"symbol"` // If ContractCode is empty, Symbol is populated
}
// Ticker latest ticker data
type Ticker struct {
Amount float64 `json:"amount"`
Close float64 `json:"close"`
Count int64 `json:"count"`
High float64 `json:"high"`
Low float64 `json:"low"`
Open float64 `json:"open"`
Symbol string `json:"symbol"`
Volume float64 `json:"vol"`
Symbol string `json:"symbol"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Amount float64 `json:"amount"`
Volume float64 `json:"vol"`
Count float64 `json:"count"`
Bid float64 `json:"bid"`
BidSize float64 `json:"bidSize"`
Ask float64 `json:"ask"`
AskSize float64 `json:"askSize"`
}
// OrderBookDataRequestParamsType var for request param types

View File

@@ -109,6 +109,7 @@ func (h *HUOBI) SetDefaults() {
Websocket: true,
RESTCapabilities: protocol.Features{
TickerFetching: true,
TickerBatching: true,
KlineFetching: true,
TradeFetching: true,
OrderbookFetching: true,
@@ -141,7 +142,6 @@ func (h *HUOBI) SetDefaults() {
GetOrders: true,
TickerFetching: true,
FundingRateFetching: false, // supported but not implemented // TODO when multi-websocket support added
},
WithdrawPermissions: exchange.AutoWithdrawCryptoWithSetup |
exchange.NoFiatWithdrawals,
@@ -445,8 +445,141 @@ func (h *HUOBI) UpdateTradablePairs(ctx context.Context, forceUpdate bool) error
}
// UpdateTickers updates the ticker for all currency pairs of a given asset type
func (h *HUOBI) UpdateTickers(_ context.Context, _ asset.Item) error {
return common.ErrFunctionNotSupported
func (h *HUOBI) UpdateTickers(ctx context.Context, a asset.Item) error {
switch a {
case asset.Spot:
ticks, err := h.GetTickers(ctx)
if err != nil {
return err
}
for i := range ticks.Data {
var cp currency.Pair
cp, _, err = h.MatchSymbolCheckEnabled(ticks.Data[i].Symbol, a, false)
if err != nil {
if errors.Is(err, currency.ErrPairNotFound) {
continue
}
return err
}
err = ticker.ProcessTicker(&ticker.Price{
High: ticks.Data[i].High,
Low: ticks.Data[i].Low,
Bid: ticks.Data[i].Bid,
Ask: ticks.Data[i].Ask,
Volume: ticks.Data[i].Volume,
QuoteVolume: ticks.Data[i].Amount,
Open: ticks.Data[i].Open,
Close: ticks.Data[i].Close,
BidSize: ticks.Data[i].BidSize,
AskSize: ticks.Data[i].AskSize,
Pair: cp,
ExchangeName: h.Name,
AssetType: a,
LastUpdated: time.Now(),
})
if err != nil {
return err
}
}
case asset.CoinMarginedFutures:
ticks, err := h.GetBatchCoinMarginSwapContracts(ctx)
if err != nil {
return err
}
for i := range ticks {
var cp currency.Pair
cp, _, err = h.MatchSymbolCheckEnabled(ticks[i].ContractCode, a, true)
if err != nil {
if errors.Is(err, currency.ErrPairNotFound) {
continue
}
return err
}
tt := time.UnixMilli(ticks[i].Timestamp)
err = ticker.ProcessTicker(&ticker.Price{
High: ticks[i].High.Float64(),
Low: ticks[i].Low.Float64(),
Volume: ticks[i].Volume.Float64(),
QuoteVolume: ticks[i].Amount.Float64(),
Open: ticks[i].Open.Float64(),
Close: ticks[i].Close.Float64(),
Bid: ticks[i].Bid[0],
BidSize: ticks[i].Bid[1],
Ask: ticks[i].Ask[0],
AskSize: ticks[i].Ask[1],
Pair: cp,
ExchangeName: h.Name,
AssetType: a,
LastUpdated: tt,
})
if err != nil {
return err
}
}
case asset.Futures:
linearTicks, err := h.GetBatchLinearSwapContracts(ctx)
if err != nil {
return err
}
ticks, err := h.GetBatchFuturesContracts(ctx)
if err != nil {
return err
}
allTicks := make([]FuturesBatchTicker, 0, len(linearTicks)+len(ticks))
allTicks = append(allTicks, linearTicks...)
allTicks = append(allTicks, ticks...)
for i := range allTicks {
var cp currency.Pair
if allTicks[i].Symbol != "" {
cp, err = currency.NewPairFromString(allTicks[i].Symbol)
if err != nil {
return err
}
cp, err = h.convertContractShortHandToExpiry(cp, time.Now())
if err != nil {
return err
}
cp, _, err = h.MatchSymbolCheckEnabled(cp.String(), a, true)
if err != nil {
if errors.Is(err, currency.ErrPairNotFound) {
continue
}
return err
}
} else {
cp, _, err = h.MatchSymbolCheckEnabled(allTicks[i].ContractCode, a, true)
if err != nil {
if errors.Is(err, currency.ErrPairNotFound) {
continue
}
return err
}
}
tt := time.UnixMilli(allTicks[i].Timestamp)
err = ticker.ProcessTicker(&ticker.Price{
High: allTicks[i].High.Float64(),
Low: allTicks[i].Low.Float64(),
Volume: allTicks[i].Volume.Float64(),
QuoteVolume: allTicks[i].Amount.Float64(),
Open: allTicks[i].Open.Float64(),
Close: allTicks[i].Close.Float64(),
Bid: allTicks[i].Bid[0],
BidSize: allTicks[i].Bid[1],
Ask: allTicks[i].Ask[0],
AskSize: allTicks[i].Ask[1],
Pair: cp,
ExchangeName: h.Name,
AssetType: a,
LastUpdated: tt,
})
if err != nil {
return err
}
}
default:
return fmt.Errorf("%w %v", asset.ErrNotSupported, a)
}
return nil
}
// UpdateTicker updates and returns the ticker for a currency pair
@@ -2281,7 +2414,7 @@ func (h *HUOBI) GetLatestFundingRates(ctx context.Context, r *fundingrate.Latest
rate := fundingrate.LatestRateResponse{
Exchange: h.Name,
Asset: r.Asset,
Pair: r.Pair,
Pair: cp,
LatestRate: fundingrate.Rate{
Time: ft,
Rate: decimal.NewFromFloat(rates[i].FundingRate),

View File

@@ -2298,7 +2298,7 @@ func TestGetOpenInterest(t *testing.T) {
assert.NoError(t, err)
assert.NotEmpty(t, resp)
_, err = k.GetOpenInterest(context.Background())
resp, err = k.GetOpenInterest(context.Background())
assert.NoError(t, err)
assert.NotEmpty(t, resp)
}