exchanges/futures: Implement open interest (#1417)

* adds open interest to exchanges

* ADDS TESTING YEAH

* New endpoints, BTSE, RPCS, cached

* slight design change, begin gateio

You will need to get cached for
each exchange that supports it

* gateio, huobi, rpc

* fix up kraken, cache retrieval

* okx, gateio

* finalising all implementations and tests

* definitely my final ever commit on this

* Well, well, well

* final v2

* quick fix of bug

* test coverage, assert notempty, test helper

Added a new testhelper for currency
management because its very annoying
in a parallel test setting which wastes
so much space otherwise

* minimises REST requests for Open Interest

* types.Number merge misses

* Minimises Kraken REST calls

* len change, value -> pointer receiver

* further fixup

* fixes gateio, batch calculates open interest

* single gateio, lint const fixes

* rejig and more thorough oi for huobi

* formatting expansion

* minor fix for handling expiring contracts

* rm unused Binance strings

* add bybit support, fix bybit issues

* oopsie doopsie, dont look at my whoopsie

* Fix issue, remove feature

* move an irrelevant function for the pr

* mini bybit upgrades

* fixes cli request bug
This commit is contained in:
Scott
2024-01-12 15:27:35 +11:00
committed by GitHub
parent 614042110a
commit b71bf1f3d1
62 changed files with 22660 additions and 10095 deletions

View File

@@ -39,14 +39,23 @@ type FContractPriceLimits struct {
// FContractOIData stores open interest data for futures contracts
type FContractOIData struct {
Data []struct {
Symbol string `json:"symbol"`
ContractType string `json:"contract_type"`
Volume float64 `json:"volume"`
Amount float64 `json:"amount"`
ContractCode string `json:"contract_code"`
} `json:"data"`
Timestamp int64 `json:"ts"`
Data []UContractOpenInterest `json:"data"`
Timestamp int64 `json:"ts"`
}
// UContractOpenInterest stores open interest data for futures contracts
type UContractOpenInterest struct {
Volume float64 `json:"volume"`
Amount float64 `json:"amount"`
Symbol string `json:"symbol"`
Value float64 `json:"value"`
ContractCode string `json:"contract_code"`
TradeAmount float64 `json:"trade_amount"`
TradeVolume float64 `json:"trade_volume"`
TradeTurnover float64 `json:"trade_turnover"`
BusinessType string `json:"business_type"`
Pair string `json:"pair"`
ContractType string `json:"contract_type"`
}
// FEstimatedDeliveryPriceInfo stores estimated delivery price data for futures

View File

@@ -109,7 +109,9 @@ func (h *HUOBI) SwapOpenInterestInformation(ctx context.Context, code currency.P
return resp, err
}
params := url.Values{}
params.Set("contract_code", codeValue)
if !code.IsEmpty() {
params.Set("contract_code", codeValue)
}
path := common.EncodeURLValues(huobiSwapOpenInterestInfo, params)
return resp, h.SendHTTPRequest(ctx, exchange.RestFutures, path, &resp)
}

View File

@@ -75,6 +75,10 @@ const (
fCancelAllTriggerOrders = "/api/v1/contract_trigger_cancelall"
fTriggerOpenOrders = "/api/v1/contract_trigger_openorders"
fTriggerOrderHistory = "/api/v1/contract_trigger_hisorders"
uContractOpenInterest = "/linear-swap-api/v1/swap_open_interest"
fContractDateFormat = "060102"
)
// FGetContractInfo gets contract info for futures
@@ -140,6 +144,39 @@ func (h *HUOBI) FContractPriceLimitations(ctx context.Context, symbol, contractT
return resp, h.SendHTTPRequest(ctx, exchange.RestFutures, path, &resp)
}
// ContractOpenInterestUSDT gets open interest data for futures contracts
func (h *HUOBI) ContractOpenInterestUSDT(ctx context.Context, contractCode, pair currency.Pair, contractType, businessType string) ([]UContractOpenInterest, error) {
params := url.Values{}
if !contractCode.IsEmpty() {
cc, err := h.formatFuturesPair(contractCode, true)
if err != nil {
return nil, err
}
params.Set("contract_code", cc)
}
if !pair.IsEmpty() {
p, err := h.formatFuturesPair(pair, true)
if err != nil {
return nil, err
}
params.Set("pair", p)
}
if contractType != "" {
if !common.StringDataCompareInsensitive(validContractTypes, contractType) {
return nil, fmt.Errorf("invalid contractType")
}
params.Set("contract_type", contractType)
}
if businessType != "" {
params.Set("business_type", businessType)
}
path := common.EncodeURLValues(uContractOpenInterest, params)
var resp struct {
Data []UContractOpenInterest `json:"data"`
}
return resp.Data, h.SendHTTPRequest(ctx, exchange.RestFutures, path, &resp)
}
// FContractOpenInterest gets open interest data for futures contracts
func (h *HUOBI) FContractOpenInterest(ctx context.Context, symbol, contractType string, code currency.Pair) (FContractOIData, error) {
var resp FContractOIData
@@ -154,11 +191,11 @@ func (h *HUOBI) FContractOpenInterest(ctx context.Context, symbol, contractType
params.Set("contract_type", contractType)
}
if !code.IsEmpty() {
codeValue, err := h.FormatSymbol(code, asset.Futures)
codeValue, err := h.convertContractShortHandToExpiry(code)
if err != nil {
return resp, err
}
params.Set("contract_code", codeValue)
params.Set("contract_code", codeValue.String())
}
path := common.EncodeURLValues(fContractOpenInterest, params)
return resp, h.SendHTTPRequest(ctx, exchange.RestFutures, path, &resp)
@@ -179,7 +216,7 @@ func (h *HUOBI) FGetEstimatedDeliveryPrice(ctx context.Context, symbol currency.
// FGetMarketDepth gets market depth data for futures contracts
func (h *HUOBI) FGetMarketDepth(ctx context.Context, symbol currency.Pair, dataType string) (*OBData, error) {
symbolValue, err := h.formatFuturesPair(symbol)
symbolValue, err := h.formatFuturesPair(symbol, false)
if err != nil {
return nil, err
}
@@ -219,7 +256,7 @@ func (h *HUOBI) FGetMarketDepth(ctx context.Context, symbol currency.Pair, dataT
func (h *HUOBI) FGetKlineData(ctx context.Context, symbol currency.Pair, period string, size int64, startTime, endTime time.Time) (FKlineData, error) {
var resp FKlineData
params := url.Values{}
symbolValue, err := h.formatFuturesPair(symbol)
symbolValue, err := h.formatFuturesPair(symbol, false)
if err != nil {
return resp, err
}
@@ -246,7 +283,7 @@ func (h *HUOBI) FGetKlineData(ctx context.Context, symbol currency.Pair, period
func (h *HUOBI) FGetMarketOverviewData(ctx context.Context, symbol currency.Pair) (FMarketOverviewData, error) {
var resp FMarketOverviewData
params := url.Values{}
symbolValue, err := h.formatFuturesPair(symbol)
symbolValue, err := h.formatFuturesPair(symbol, false)
if err != nil {
return resp, err
}
@@ -259,7 +296,7 @@ func (h *HUOBI) FGetMarketOverviewData(ctx context.Context, symbol currency.Pair
func (h *HUOBI) FLastTradeData(ctx context.Context, symbol currency.Pair) (FLastTradeData, error) {
var resp FLastTradeData
params := url.Values{}
symbolValue, err := h.formatFuturesPair(symbol)
symbolValue, err := h.formatFuturesPair(symbol, false)
if err != nil {
return resp, err
}
@@ -271,7 +308,7 @@ func (h *HUOBI) FLastTradeData(ctx context.Context, symbol currency.Pair) (FLast
// FRequestPublicBatchTrades gets public batch trades for a futures contract
func (h *HUOBI) FRequestPublicBatchTrades(ctx context.Context, symbol currency.Pair, size int64) (FBatchTradesForContractData, error) {
params := url.Values{}
symbolValue, err := h.formatFuturesPair(symbol)
symbolValue, err := h.formatFuturesPair(symbol, false)
if err != nil {
return FBatchTradesForContractData{}, err
}
@@ -432,7 +469,7 @@ func (h *HUOBI) FLiquidationOrders(ctx context.Context, symbol currency.Code, tr
func (h *HUOBI) FIndexKline(ctx context.Context, symbol currency.Pair, period string, size int64) (FIndexKlineData, error) {
var resp FIndexKlineData
params := url.Values{}
symbolValue, err := h.formatFuturesPair(symbol)
symbolValue, err := h.formatFuturesPair(symbol, false)
if err != nil {
return resp, err
}
@@ -453,7 +490,7 @@ func (h *HUOBI) FIndexKline(ctx context.Context, symbol currency.Pair, period st
func (h *HUOBI) FGetBasisData(ctx context.Context, symbol currency.Pair, period, basisPriceType string, size int64) (FBasisData, error) {
var resp FBasisData
params := url.Values{}
symbolValue, err := h.formatFuturesPair(symbol)
symbolValue, err := h.formatFuturesPair(symbol, false)
if err != nil {
return resp, err
}
@@ -1216,9 +1253,58 @@ func (h *HUOBI) formatFuturesCode(p currency.Code) (string, error) {
}
// formatFuturesPair handles pairs in the format as "BTC-NW" and "BTC210827"
func (h *HUOBI) formatFuturesPair(p currency.Pair) (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)
if err != nil {
return "", err
}
return cp.String(), nil
}
return p.Format(currency.PairFormat{Delimiter: "_", Uppercase: true}).String(), nil
}
if p.Quote.IsStableCurrency() {
return p.Format(currency.PairFormat{Delimiter: "-", Uppercase: true}).String(), nil
}
return h.FormatSymbol(p, asset.Futures)
}
// 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)
}
tt := time.Now()
switch pair.Quote.Item.Symbol {
case "NW":
tt = tt.AddDate(0, 0, 7)
fallthrough
case "CW":
for {
if tt.Weekday() == time.Friday {
break
}
tt = tt.AddDate(0, 0, 1)
}
case "NQ":
tt = tt.AddDate(0, 3, 0)
fallthrough
case "CQ":
// Find the next quarter end
for !(tt.Month() == time.March || tt.Month() == time.June || tt.Month() == time.September || tt.Month() == time.December) {
tt = tt.AddDate(0, 1, 0)
}
// Find the last day of the quarter
tt = time.Date(tt.Year(), tt.Month()+1, 0, 0, 0, 0, 0, time.UTC)
// Find the last Friday of the quarter
for tt.Weekday() != time.Friday {
tt = tt.AddDate(0, 0, -1)
}
}
pair.Quote = currency.NewCode(tt.Format(fContractDateFormat))
return pair, nil
}

View File

@@ -12,7 +12,9 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/common/key"
"github.com/thrasher-corp/gocryptotrader/config"
"github.com/thrasher-corp/gocryptotrader/core"
"github.com/thrasher-corp/gocryptotrader/currency"
@@ -2688,7 +2690,7 @@ func TestGetAvailableTransferChains(t *testing.T) {
}
func TestFormatFuturesPair(t *testing.T) {
r, err := h.formatFuturesPair(futuresTestPair)
r, err := h.formatFuturesPair(futuresTestPair, false)
if err != nil {
t.Error(err)
}
@@ -2704,7 +2706,7 @@ func TestFormatFuturesPair(t *testing.T) {
}
// test getting a tradable pair in the format of BTC210827 but make it lower
// case to test correct formatting
r, err = h.formatFuturesPair(availInstruments[0])
r, err = h.formatFuturesPair(availInstruments[0], false)
if err != nil {
t.Error(err)
}
@@ -2714,6 +2716,22 @@ func TestFormatFuturesPair(t *testing.T) {
if !strings.Contains(r, "BTC") {
t.Errorf("expected %s, got %s", "BTC220708", r)
}
r, err = h.formatFuturesPair(futuresTestPair, true)
if err != nil {
t.Error(err)
}
if r == "BTC_CW" {
t.Errorf("expected BTC{{date}}, got %s", r)
}
r, err = h.formatFuturesPair(currency.NewPair(currency.BTC, currency.USDT), false)
if err != nil {
t.Error(err)
}
if r != "BTC-USDT" {
t.Errorf("expected BTC-USDT, got %s", r)
}
}
func TestSearchForExistedWithdrawsAndDeposits(t *testing.T) {
@@ -2841,3 +2859,95 @@ func TestGetSwapFundingRates(t *testing.T) {
t.Error(err)
}
}
func TestGetOpenInterest(t *testing.T) {
t.Parallel()
_, err := h.GetOpenInterest(context.Background(), key.PairAsset{
Base: currency.ETH.Item,
Quote: currency.USDT.Item,
Asset: asset.USDTMarginedFutures,
})
assert.ErrorIs(t, err, asset.ErrNotSupported)
resp, err := h.GetOpenInterest(context.Background(), key.PairAsset{
Base: currency.BTC.Item,
Quote: currency.USD.Item,
Asset: asset.CoinMarginedFutures,
})
assert.NoError(t, err)
assert.NotEmpty(t, resp)
resp, err = h.GetOpenInterest(context.Background(), key.PairAsset{
Base: futuresTestPair.Base.Item,
Quote: futuresTestPair.Quote.Item,
Asset: asset.Futures,
})
assert.NoError(t, err)
assert.NotEmpty(t, resp)
resp, err = h.GetOpenInterest(context.Background())
assert.NoError(t, err)
assert.NotEmpty(t, resp)
}
func TestContractOpenInterestUSDT(t *testing.T) {
t.Parallel()
resp, err := h.ContractOpenInterestUSDT(context.Background(), currency.EMPTYPAIR, currency.EMPTYPAIR, "", "")
assert.NoError(t, err)
assert.NotEmpty(t, resp)
cp := currency.NewPair(currency.BTC, currency.USDT)
resp, err = h.ContractOpenInterestUSDT(context.Background(), cp, currency.EMPTYPAIR, "", "")
assert.NoError(t, err)
assert.NotEmpty(t, resp)
resp, err = h.ContractOpenInterestUSDT(context.Background(), currency.EMPTYPAIR, cp, "", "")
assert.NoError(t, err)
assert.NotEmpty(t, resp)
resp, err = h.ContractOpenInterestUSDT(context.Background(), cp, currency.EMPTYPAIR, "this_week", "")
assert.NoError(t, err)
assert.NotEmpty(t, resp)
resp, err = h.ContractOpenInterestUSDT(context.Background(), currency.EMPTYPAIR, currency.EMPTYPAIR, "", "swap")
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

@@ -12,6 +12,7 @@ import (
"github.com/shopspring/decimal"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/common/key"
"github.com/thrasher-corp/gocryptotrader/config"
"github.com/thrasher-corp/gocryptotrader/currency"
exchange "github.com/thrasher-corp/gocryptotrader/exchanges"
@@ -80,7 +81,7 @@ func (h *HUOBI) SetDefaults() {
Delimiter: currency.DashDelimiter,
},
}
futures := currency.PairStore{
futuresFormatting := currency.PairStore{
RequestFormat: &currency.PairFormat{
Uppercase: true,
},
@@ -97,7 +98,7 @@ func (h *HUOBI) SetDefaults() {
if err != nil {
log.Errorln(log.ExchangeSys, err)
}
err = h.StoreAssetPairFormat(asset.Futures, futures)
err = h.StoreAssetPairFormat(asset.Futures, futuresFormatting)
if err != nil {
log.Errorln(log.ExchangeSys, err)
}
@@ -155,6 +156,10 @@ func (h *HUOBI) SetDefaults() {
FundingRateBatching: map[asset.Item]bool{
asset.CoinMarginedFutures: true,
},
OpenInterest: exchange.OpenInterestSupport{
Supported: true,
SupportsRestBatch: true,
},
},
},
Enabled: exchange.FeaturesEnabled{
@@ -2304,3 +2309,159 @@ func (h *HUOBI) IsPerpetualFutureCurrency(a asset.Item, _ currency.Pair) (bool,
func (h *HUOBI) UpdateOrderExecutionLimits(_ context.Context, _ asset.Item) error {
return common.ErrNotYetImplemented
}
// GetOpenInterest returns the open interest rate for a given asset pair
func (h *HUOBI) GetOpenInterest(ctx context.Context, k ...key.PairAsset) ([]futures.OpenInterest, error) {
for i := range k {
if k[i].Asset != asset.Futures && k[i].Asset != asset.CoinMarginedFutures {
// avoid API calls or returning errors after a successful retrieval
return nil, fmt.Errorf("%w %v %v", asset.ErrNotSupported, k[i].Asset, k[i].Pair())
}
}
if len(k) == 1 {
switch k[0].Asset {
case asset.Futures:
_, err := strconv.ParseInt(k[0].Quote.Symbol, 10, 64)
if err == nil {
// Huobi does not like requests being made with contract expiry in them (eg BTC240109)
return nil, fmt.Errorf("%w %v, must use shorthand such as CW (current week)", currency.ErrCurrencyNotSupported, k[0].Pair())
}
data, err := h.FContractOpenInterest(ctx, "", "", k[0].Pair())
if err != nil {
data2, err2 := h.ContractOpenInterestUSDT(ctx, k[0].Pair(), currency.EMPTYPAIR, "", "")
if err2 != nil {
return nil, fmt.Errorf("%w %w", err, err2)
}
data.Data = data2
}
for i := range data.Data {
var p currency.Pair
p, err = h.MatchSymbolWithAvailablePairs(data.Data[i].ContractCode, k[0].Asset, true)
if err != nil {
if errors.Is(err, currency.ErrPairNotFound) {
continue
}
return nil, err
}
return []futures.OpenInterest{
{
Key: key.ExchangePairAsset{
Exchange: h.Name,
Base: p.Base.Item,
Quote: p.Quote.Item,
Asset: k[0].Asset,
},
OpenInterest: data.Data[i].Amount,
},
}, nil
}
case asset.CoinMarginedFutures:
data, err := h.SwapOpenInterestInformation(ctx, k[0].Pair())
if err != nil {
return nil, err
}
for i := range data.Data {
var p currency.Pair
p, err = h.MatchSymbolWithAvailablePairs(data.Data[i].ContractCode, k[0].Asset, true)
if err != nil {
if errors.Is(err, currency.ErrPairNotFound) {
continue
}
return nil, err
}
return []futures.OpenInterest{
{
Key: key.ExchangePairAsset{
Exchange: h.Name,
Base: p.Base.Item,
Quote: p.Quote.Item,
Asset: k[0].Asset,
},
OpenInterest: data.Data[i].Amount,
},
}, nil
}
}
}
var resp []futures.OpenInterest
for _, a := range h.GetAssetTypes(true) {
switch a {
case asset.Futures:
data, err := h.FContractOpenInterest(ctx, "", "", currency.EMPTYPAIR)
if err != nil {
return nil, err
}
uData, err := h.ContractOpenInterestUSDT(ctx, currency.EMPTYPAIR, currency.EMPTYPAIR, "", "")
if err != nil {
return nil, err
}
allData := make([]UContractOpenInterest, 0, len(data.Data)+len(uData))
allData = append(allData, data.Data...)
allData = append(allData, uData...)
for i := range allData {
var p currency.Pair
var isEnabled, appendData bool
p, isEnabled, err = h.MatchSymbolCheckEnabled(allData[i].ContractCode, a, true)
if err != nil && !errors.Is(err, currency.ErrPairNotFound) {
return nil, err
}
if !isEnabled {
continue
}
for j := range k {
if k[j].Pair().Equal(p) {
appendData = true
break
}
}
if len(k) > 0 && !appendData {
continue
}
resp = append(resp, futures.OpenInterest{
Key: key.ExchangePairAsset{
Exchange: h.Name,
Base: p.Base.Item,
Quote: p.Quote.Item,
Asset: a,
},
OpenInterest: allData[i].Amount,
})
}
case asset.CoinMarginedFutures:
data, err := h.SwapOpenInterestInformation(ctx, currency.EMPTYPAIR)
if err != nil {
return nil, err
}
for i := range data.Data {
p, isEnabled, err := h.MatchSymbolCheckEnabled(data.Data[i].ContractCode, a, true)
if err != nil && !errors.Is(err, currency.ErrPairNotFound) {
return nil, err
}
if !isEnabled {
continue
}
var appendData bool
for j := range k {
if k[j].Pair().Equal(p) {
appendData = true
break
}
}
if len(k) > 0 && !appendData {
continue
}
resp = append(resp, futures.OpenInterest{
Key: key.ExchangePairAsset{
Exchange: h.Name,
Base: p.Base.Item,
Quote: p.Quote.Item,
Asset: a,
},
OpenInterest: data.Data[i].Amount,
})
}
}
}
return resp, nil
}