diff --git a/exchanges/huobi/huobi.go b/exchanges/huobi/huobi.go index 467d25f3..9f9b7986 100644 --- a/exchanges/huobi/huobi.go +++ b/exchanges/huobi/huobi.go @@ -10,6 +10,7 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "github.com/thrasher-corp/gocryptotrader/common" @@ -71,14 +72,16 @@ const ( huobiCurrenciesReference = "/v2/reference/currencies" huobiWithdrawHistory = "/query/deposit-withdraw" huobiBatchCoinMarginSwapContracts = "/v2/swap-ex/market/detail/batch_merged" - huobiBatchLinearSwapContracts = "/linear-swap-ex/market/detail/batch_merged" + huobiBatchLinearSwapContracts = "/v2/linear-swap-ex/market/detail/batch_merged" huobiBatchContracts = "/v2/market/detail/batch_merged" ) // HUOBI is the overarching type across this package type HUOBI struct { exchange.Base - AccountID string + AccountID string + futureContractCodesMutex sync.RWMutex + futureContractCodes map[string]currency.Code } // GetMarginRates gets margin rates diff --git a/exchanges/huobi/huobi_futures.go b/exchanges/huobi/huobi_futures.go index edfa2a02..b779d9fe 100644 --- a/exchanges/huobi/huobi_futures.go +++ b/exchanges/huobi/huobi_futures.go @@ -9,7 +9,9 @@ import ( "io" "net/http" "net/url" + "slices" "strconv" + "strings" "time" "github.com/thrasher-corp/gocryptotrader/common" @@ -77,11 +79,12 @@ const ( fTriggerOrderHistory = "/api/v1/contract_trigger_hisorders" uContractOpenInterest = "/linear-swap-api/v1/swap_open_interest" - - fContractDateFormat = "060102" ) -var errInvalidContractType = errors.New("invalid contract type") +var ( + errInvalidContractType = errors.New("invalid contract type") + errInconsistentContractExpiry = errors.New("inconsistent contract expiry date codes") +) // FGetContractInfo gets contract info for futures func (h *HUOBI) FGetContractInfo(ctx context.Context, symbol, contractType string, code currency.Pair) (FContractInfoData, error) { @@ -90,11 +93,11 @@ func (h *HUOBI) FGetContractInfo(ctx context.Context, symbol, contractType strin if symbol != "" { params.Set("symbol", symbol) } - if contractType != "" { - if !common.StringSliceCompareInsensitive(validContractTypes, contractType) { - return resp, errors.New("invalid contractType") + if t := strings.ToLower(contractType); t != "" { + if _, ok := contractExpiryNames[t]; !ok { + return resp, fmt.Errorf("%w: %v", errInvalidContractType, t) } - params.Set("contract_type", contractType) + params.Set("contract_type", t) } if !code.IsEmpty() { codeValue, err := h.FormatSymbol(code, asset.Futures) @@ -129,11 +132,11 @@ func (h *HUOBI) FContractPriceLimitations(ctx context.Context, symbol, contractT if symbol != "" { params.Set("symbol", symbol) } - if contractType != "" { - if !common.StringSliceCompareInsensitive(validContractTypes, contractType) { - return resp, fmt.Errorf("invalid contractType: %s", contractType) + if t := strings.ToLower(contractType); t != "" { + if _, ok := contractExpiryNames[t]; !ok { + return resp, fmt.Errorf("%w: %v", errInvalidContractType, t) } - params.Set("contract_type", contractType) + params.Set("contract_type", t) } if !code.IsEmpty() { codeValue, err := h.FormatSymbol(code, asset.Futures) @@ -163,11 +166,11 @@ func (h *HUOBI) ContractOpenInterestUSDT(ctx context.Context, contractCode, pair } params.Set("pair", p) } - if contractType != "" { - if !common.StringSliceCompareInsensitive(validContractTypes, contractType) { - return nil, errors.New("invalid contractType") + if t := strings.ToLower(contractType); t != "" { + if _, ok := contractExpiryNames[t]; !ok { + return nil, fmt.Errorf("%w: %v", errInvalidContractType, t) } - params.Set("contract_type", contractType) + params.Set("contract_type", t) } if businessType != "" { params.Set("business_type", businessType) @@ -186,11 +189,11 @@ func (h *HUOBI) FContractOpenInterest(ctx context.Context, symbol, contractType if symbol != "" { params.Set("symbol", symbol) } - if contractType != "" { - if !common.StringSliceCompareInsensitive(validContractTypes, contractType) { - return resp, errors.New("invalid contractType") + if t := strings.ToLower(contractType); t != "" { + if _, ok := contractExpiryNames[t]; !ok { + return resp, fmt.Errorf("%w: %v", errInvalidContractType, t) } - params.Set("contract_type", contractType) + params.Set("contract_type", t) } if !code.IsEmpty() { codeValue, err := h.formatFuturesPair(code, true) @@ -375,8 +378,9 @@ func (h *HUOBI) FQueryHisOpenInterest(ctx context.Context, symbol, contractType, if symbol != "" { params.Set("symbol", symbol) } - if !common.StringSliceCompareInsensitive(validContractTypes, contractType) { - return resp, fmt.Errorf("%w %v", errInvalidContractType, contractType) + contractType = strings.ToLower(contractType) + if _, ok := contractExpiryNames[contractType]; !ok { + return resp, fmt.Errorf("%w: %v", errInvalidContractType, contractType) } params.Set("contract_type", contractType) if !common.StringSliceCompareInsensitive(validPeriods, period) { @@ -749,11 +753,11 @@ func (h *HUOBI) FOrder(ctx context.Context, contractCode currency.Pair, symbol, if symbol != "" { req["symbol"] = symbol } - if contractType != "" { - if !common.StringSliceCompareInsensitive(validContractTypes, contractType) { - return resp, errors.New("invalid contractType") + if t := strings.ToLower(contractType); t != "" { + if _, ok := contractExpiryNames[t]; !ok { + return resp, fmt.Errorf("%w: %v", errInvalidContractType, t) } - req["contract_type"] = contractType + req["contract_type"] = t } if !contractCode.IsEmpty() { codeValue, err := h.FormatSymbol(contractCode, asset.Futures) @@ -807,8 +811,8 @@ func (h *HUOBI) FPlaceBatchOrder(ctx context.Context, data []fBatchOrderData) (F data[x].ContractCode = formattedPair.String() } if data[x].ContractType != "" { - if !common.StringSliceCompareInsensitive(validContractTypes, data[x].ContractType) { - return resp, errors.New("invalid contractType") + if _, ok := contractExpiryNames[strings.ToLower(data[x].ContractType)]; !ok { + return resp, fmt.Errorf("%w %v", errInvalidContractType, data[x].ContractType) } } if !common.StringSliceCompareInsensitive(validOffsetTypes, data[x].Offset) { @@ -846,11 +850,11 @@ func (h *HUOBI) FCancelAllOrders(ctx context.Context, contractCode currency.Pair if symbol != "" { req["symbol"] = symbol } - if contractType != "" { - if !common.StringSliceCompareInsensitive(validContractTypes, contractType) { - return resp, errors.New("invalid contractType") + if t := strings.ToLower(contractType); t != "" { + if _, ok := contractExpiryNames[t]; !ok { + return resp, fmt.Errorf("%w: %v", errInvalidContractType, t) } - req["contract_type"] = contractType + req["contract_type"] = t } if !contractCode.IsEmpty() { codeValue, err := h.FormatSymbol(contractCode, asset.Futures) @@ -867,11 +871,11 @@ func (h *HUOBI) FFlashCloseOrder(ctx context.Context, contractCode currency.Pair var resp FOrderData req := make(map[string]interface{}) req["symbol"] = symbol - if contractType != "" { - if !common.StringSliceCompareInsensitive(validContractTypes, contractType) { - return resp, errors.New("invalid contractType") + if t := strings.ToLower(contractType); t != "" { + if _, ok := contractExpiryNames[t]; !ok { + return resp, fmt.Errorf("%w: %v", errInvalidContractType, t) } - req["contract_type"] = contractType + req["contract_type"] = t } if !contractCode.IsEmpty() { codeValue, err := h.FormatSymbol(contractCode, asset.Futures) @@ -1039,11 +1043,11 @@ func (h *HUOBI) FPlaceTriggerOrder(ctx context.Context, contractCode currency.Pa if symbol != "" { req["symbol"] = symbol } - if contractType != "" { - if !common.StringSliceCompareInsensitive(validContractTypes, contractType) { - return resp, fmt.Errorf("invalid contractType: %s", contractType) + if t := strings.ToLower(contractType); t != "" { + if _, ok := contractExpiryNames[t]; !ok { + return resp, fmt.Errorf("%w: %v", errInvalidContractType, t) } - req["contract_type"] = contractType + req["contract_type"] = t } if !contractCode.IsEmpty() { codeValue, err := h.FormatSymbol(contractCode, asset.Futures) @@ -1094,11 +1098,11 @@ func (h *HUOBI) FCancelAllTriggerOrders(ctx context.Context, contractCode curren } req["contract_code"] = codeValue } - if contractType != "" { - if !common.StringSliceCompareInsensitive(validContractTypes, contractType) { - return resp, nil + if t := strings.ToLower(contractType); t != "" { + if _, ok := contractExpiryNames[t]; !ok { + return resp, fmt.Errorf("%w: %v", errInvalidContractType, t) } - req["contract_type"] = contractType + req["contract_type"] = t } return resp, h.FuturesAuthenticatedHTTPRequest(ctx, exchange.RestFutures, http.MethodPost, fCancelAllTriggerOrders, nil, req, &resp) } @@ -1256,9 +1260,9 @@ 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, convertQuoteToExpiry bool) (string, error) { - if common.StringSliceCompareInsensitive(validContractShortTypes, p.Quote.String()) { + if slices.Contains(validContractExpiryCodes, strings.ToUpper(p.Quote.String())) { if convertQuoteToExpiry { - cp, err := h.convertContractShortHandToExpiry(p, time.Now()) + cp, err := h.pairFromContractExpiryCode(p) if err != nil { return "", err } @@ -1273,42 +1277,16 @@ func (h *HUOBI) formatFuturesPair(p currency.Pair, convertQuoteToExpiry bool) (s 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, tt time.Time) (currency.Pair, error) { - loc, err := time.LoadLocation("Asia/Singapore") - if err != nil { - return currency.EMPTYPAIR, err +// pairFromContractExpiryCode converts a pair with contract expiry shorthand in the Quote to a concrete tradable pair +// We need this because some apis, such as ticker, use BTC_CW, NW, CQ, NQ +// Other apis, such as contract_info, use contract type of this_week, next_week, quarter (sic), and next_quater +func (h *HUOBI) pairFromContractExpiryCode(p currency.Pair) (currency.Pair, error) { + h.futureContractCodesMutex.RLock() + defer h.futureContractCodesMutex.RUnlock() + exp, ok := h.futureContractCodes[p.Quote.String()] + if !ok { + return p, fmt.Errorf("%w: %s", errInvalidContractType, p.Quote.String()) } - tt = tt.In(loc) - 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) - } - default: - return currency.EMPTYPAIR, fmt.Errorf(" %w %v", errInvalidContractType, pair) - } - pair.Quote = currency.NewCode(tt.Format(fContractDateFormat)) - return pair, nil + p.Quote = exp + return p, nil } diff --git a/exchanges/huobi/huobi_test.go b/exchanges/huobi/huobi_test.go index 3d83a23d..3f39aa4c 100644 --- a/exchanges/huobi/huobi_test.go +++ b/exchanges/huobi/huobi_test.go @@ -6,7 +6,6 @@ import ( "log" "os" "strconv" - "strings" "testing" "time" @@ -633,9 +632,7 @@ func TestFQueryTriggerOrderHistory(t *testing.T) { func TestFetchTradablePairs(t *testing.T) { t.Parallel() _, err := h.FetchTradablePairs(context.Background(), asset.Futures) - if err != nil { - t.Error(err) - } + require.NoError(t, err) } func TestUpdateTickerSpot(t *testing.T) { @@ -2674,49 +2671,33 @@ 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 { - t.Error(err) - } - if r != "BTC_CW" { - t.Errorf("expected BTC_CW, got %s", r) - } - availInstruments, err := h.FetchTradablePairs(context.Background(), asset.Futures) - if err != nil { - t.Error(err) - } - if len(availInstruments) == 0 { - t.Error("expected instruments, got 0") - } - // 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], false) - if err != nil { - t.Error(err) - } + require.NoError(t, err) + assert.Equal(t, "BTC_CW", r) + + p, err := h.FetchTradablePairs(context.Background(), asset.Futures) + require.NoError(t, err, "FetchTradablePairs must not error") + require.NotEmpty(t, p, "FetchTradablePairs must return pairs") + + // test getting a tradable pair in the format of BTC210827 but make it lower case to test correct formatting + r, err = h.formatFuturesPair(p[0].Lower(), false) + require.NoError(t, err) + assert.Len(t, r, 9, "Should be an 9 character string") // Test for upper case 'BTC' not lower case 'btc', disregarded numerals // as they not deterministic from this endpoint. - if !strings.Contains(r, "BTC") { - t.Errorf("expected %s, got %s", "BTC220708", r) - } + assert.Equal(t, "BTC", r[0:3]) r, err = h.formatFuturesPair(futuresTestPair, true) - if err != nil { - t.Error(err) - } - if r == "BTC_CW" { - t.Errorf("expected BTC{{date}}, got %s", r) - } + require.NoError(t, err) + assert.Len(t, r, 9, "Should be an 9 character string") + assert.Equal(t, "BTC", r[0:3]) 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) - } + require.NoError(t, err) + assert.Equal(t, "BTC-USDT", r) } func TestSearchForExistedWithdrawsAndDeposits(t *testing.T) { @@ -2870,74 +2851,52 @@ 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) - + require.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) + for _, p := range avail { + _, err = ticker.GetTicker(h.Name, p, a) + assert.NoErrorf(t, err, "Could not get ticker for %s %s", a, p) } } } -func TestConvertContractShortHandToExpiry(t *testing.T) { +func TestPairFromContractExpiryCode(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, "CW", cp.Quote.String()) - tick, err := h.FetchTicker(context.Background(), cp, asset.Futures) - if assert.NoError(t, err) { - assert.NotZero(t, tick.Close) + + h := new(HUOBI) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(h), "Test Instance Setup must not fail") + + _, err := h.FetchTradablePairs(context.Background(), asset.Futures) + require.NoError(t, err) + + n := time.Now().Truncate(24 * time.Hour) + for _, cType := range contractExpiryNames { + p, err := h.pairFromContractExpiryCode(currency.Pair{ + Base: currency.BTC, + Quote: currency.NewCode(cType), + }) + if cType == "NQ" && err != nil { + continue // Next Quarter is intermittently present + } + require.NoErrorf(t, err, "pairFromContractExpiryCode must not error for %s code", cType) + assert.Equal(t, currency.BTC, p.Base, "pair Base should be the same") + h.futureContractCodesMutex.RLock() + exp, ok := h.futureContractCodes[cType] + h.futureContractCodesMutex.RUnlock() + require.True(t, ok, "%s type must be in contractExpiryNames", cType) + assert.Equal(t, currency.BTC, p.Base, "pair Base should be the same") + assert.Equal(t, exp, p.Quote, "pair Quote should be the same") + d, err := time.Parse("060102", p.Quote.String()) + require.NoError(t, err, "currency code must be a parsable date") + require.Falsef(t, d.Before(n), "%s expiry must be today or after", cType) + switch cType { + case "CW", "NW": + require.True(t, d.Before(n.Add(24*time.Hour*14)), "%s expiry must be within 2 weeks", cType) + case "CQ", "NQ": + require.True(t, d.Before(n.Add(24*time.Hour*90*2)), "%s expiry must be within 2 quarters", cType) + } } - - cp = currency.NewPair(currency.BTC, currency.NewCode("NW")) - cp, err = h.convertContractShortHandToExpiry(cp, tt) - assert.NoError(t, err) - assert.NotEqual(t, "NW", cp.Quote.String()) - 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, "CQ", cp.Quote.String()) - 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, "210625", cp.Quote.String()) - - cp = currency.NewPair(currency.BTC, currency.NewCode("CW")) - cp, err = h.convertContractShortHandToExpiry(cp, tt) - assert.NoError(t, err) - assert.Equal(t, "210604", cp.Quote.String()) - - 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, "NQ", cp.Quote.String()) - 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) { @@ -2948,7 +2907,6 @@ func TestGetOpenInterest(t *testing.T) { 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, @@ -2964,7 +2922,6 @@ func TestGetOpenInterest(t *testing.T) { }) assert.NoError(t, err) assert.NotEmpty(t, resp) - resp, err = h.GetOpenInterest(context.Background()) assert.NoError(t, err) assert.NotEmpty(t, resp) diff --git a/exchanges/huobi/huobi_types.go b/exchanges/huobi/huobi_types.go index e59ff232..dd37bd39 100644 --- a/exchanges/huobi/huobi_types.go +++ b/exchanges/huobi/huobi_types.go @@ -1166,13 +1166,14 @@ var ( "reduceShort": 12, } - validContractTypes = []string{ - "this_week", "next_week", "quarter", "next_quarter", + contractExpiryNames = map[string]string{ + "this_week": "CW", + "next_week": "NW", + "quarter": "CQ", + "next_quarter": "NQ", } - validContractShortTypes = []string{ - "cw", "nw", "cq", "nq", - } + validContractExpiryCodes = []string{"CW", "NW", "CQ", "NQ"} validFuturesPeriods = []string{ "1min", "5min", "15min", "30min", "60min", "1hour", "4hour", "1day", diff --git a/exchanges/huobi/huobi_wrapper.go b/exchanges/huobi/huobi_wrapper.go index 3efcc74c..879b5285 100644 --- a/exchanges/huobi/huobi_wrapper.go +++ b/exchanges/huobi/huobi_wrapper.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "sort" "strconv" "strings" @@ -244,7 +245,6 @@ func (h *HUOBI) FetchTradablePairs(ctx context.Context, a asset.Item) (currency. } var pairs []currency.Pair - var pair currency.Pair switch a { case asset.Spot: symbols, err := h.GetSymbols(ctx) @@ -258,7 +258,7 @@ func (h *HUOBI) FetchTradablePairs(ctx context.Context, a asset.Item) (currency. continue } - pair, err = currency.NewPairFromStrings(symbols[x].BaseCurrency, + pair, err := currency.NewPairFromStrings(symbols[x].BaseCurrency, symbols[x].QuoteCurrency) if err != nil { return nil, err @@ -288,16 +288,30 @@ func (h *HUOBI) FetchTradablePairs(ctx context.Context, a asset.Item) (currency. return nil, err } pairs = make([]currency.Pair, 0, len(symbols.Data)) - for c := range symbols.Data { - if symbols.Data[c].ContractStatus != 1 { + expiryCodeDates := map[string]currency.Code{} + for _, c := range symbols.Data { + if c.ContractStatus != 1 { continue } - pair, err := currency.NewPairFromString(symbols.Data[c].ContractCode) + pair, err := currency.NewPairFromString(c.ContractCode) if err != nil { return nil, err } pairs = append(pairs, pair) + if cType, ok := contractExpiryNames[c.ContractType]; ok { + if v, ok := expiryCodeDates[cType]; !ok { + expiryCodeDates[cType] = currency.NewCode(pair.Quote.String()) + } else if v.String() != pair.Quote.String() { + return nil, fmt.Errorf("%w: %s (%s vs %s)", errInconsistentContractExpiry, cType, v.String(), pair.Quote.String()) + } + } } + // We cache contract expiries on the exchange locally right now because there's no exchange base holder for them + // It's not as dangerous as it seems, because when contracts change, so would tradeable pairs, + // so by caching them in FetchTradablePairs we're not adding any extra-layer of out-of-date data + h.futureContractCodesMutex.Lock() + h.futureContractCodes = expiryCodeDates + h.futureContractCodesMutex.Unlock() } return pairs, nil } @@ -321,6 +335,7 @@ 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(ctx context.Context, a asset.Item) error { + var errs error switch a { case asset.Spot: ticks, err := h.GetTickers(ctx) @@ -331,10 +346,10 @@ func (h *HUOBI) UpdateTickers(ctx context.Context, a asset.Item) error { var cp currency.Pair cp, _, err = h.MatchSymbolCheckEnabled(ticks.Data[i].Symbol, a, false) if err != nil { - if errors.Is(err, currency.ErrPairNotFound) { - continue + if !errors.Is(err, currency.ErrPairNotFound) { + errs = common.AppendError(errs, err) } - return err + continue } err = ticker.ProcessTicker(&ticker.Price{ High: ticks.Data[i].High, @@ -353,7 +368,7 @@ func (h *HUOBI) UpdateTickers(ctx context.Context, a asset.Item) error { LastUpdated: time.Now(), }) if err != nil { - return err + errs = common.AppendError(errs, err) } } case asset.CoinMarginedFutures: @@ -365,10 +380,10 @@ func (h *HUOBI) UpdateTickers(ctx context.Context, a asset.Item) error { var cp currency.Pair cp, _, err = h.MatchSymbolCheckEnabled(ticks[i].ContractCode, a, true) if err != nil { - if errors.Is(err, currency.ErrPairNotFound) { - continue + if !errors.Is(err, currency.ErrPairNotFound) { + errs = common.AppendError(errs, err) } - return err + continue } tt := time.UnixMilli(ticks[i].Timestamp) err = ticker.ProcessTicker(&ticker.Price{ @@ -388,73 +403,67 @@ func (h *HUOBI) UpdateTickers(ctx context.Context, a asset.Item) error { LastUpdated: tt, }) if err != nil { - return err + errs = common.AppendError(errs, err) } } case asset.Futures: - linearTicks, err := h.GetBatchLinearSwapContracts(ctx) - if err != nil { - return err + ticks := []FuturesBatchTicker{} + // TODO: Linear swap contracts are coin-m assets + if coinMTicks, err := h.GetBatchLinearSwapContracts(ctx); err != nil { + errs = common.AppendError(errs, err) + } else { + ticks = append(ticks, coinMTicks...) } - ticks, err := h.GetBatchFuturesContracts(ctx) - if err != nil { - return err + if futureTicks, err := h.GetBatchFuturesContracts(ctx); err != nil { + errs = common.AppendError(errs, err) + } else { + ticks = append(ticks, futureTicks...) } - allTicks := make([]FuturesBatchTicker, 0, len(linearTicks)+len(ticks)) - allTicks = append(allTicks, linearTicks...) - allTicks = append(allTicks, ticks...) - for i := range allTicks { + for i := range ticks { var cp currency.Pair - if allTicks[i].Symbol != "" { - cp, err = currency.NewPairFromString(allTicks[i].Symbol) - if err != nil { - return err + var err error + if ticks[i].Symbol != "" { + cp, err = currency.NewPairFromString(ticks[i].Symbol) + if err == nil { + cp, err = h.pairFromContractExpiryCode(cp) } - 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 + if err == nil { + cp, _, err = h.MatchSymbolCheckEnabled(cp.String(), a, true) } } else { - cp, _, err = h.MatchSymbolCheckEnabled(allTicks[i].ContractCode, a, true) - if err != nil { - if errors.Is(err, currency.ErrPairNotFound) { - continue - } - return err - } + cp, _, err = h.MatchSymbolCheckEnabled(ticks[i].ContractCode, a, true) } - tt := time.UnixMilli(allTicks[i].Timestamp) + if err != nil { + if !errors.Is(err, currency.ErrPairNotFound) { + errs = common.AppendError(errs, err) + } + continue + } + tt := time.UnixMilli(ticks[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], + 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 + errs = common.AppendError(errs, err) } } default: return fmt.Errorf("%w %v", asset.ErrNotSupported, a) } - return nil + return errs } // UpdateTicker updates and returns the ticker for a currency pair @@ -2329,8 +2338,7 @@ func (h *HUOBI) GetOpenInterest(ctx context.Context, k ...key.PairAsset) ([]futu if len(k) == 1 { switch k[0].Asset { case asset.Futures: - _, err := strconv.ParseInt(k[0].Quote.Symbol, 10, 64) - if err == nil { + if !slices.Contains(validContractExpiryCodes, strings.ToUpper(k[0].Pair().Quote.String())) { // 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()) }