From 21d672f35f47a69b3c7329d11b2e639820327677 Mon Sep 17 00:00:00 2001 From: Anatoly Date: Tue, 11 Feb 2025 19:02:10 +0700 Subject: [PATCH] Make pre-release updates. - fix rounding functions - move all tests to testnet - improve client structure - fix the bug with a cancel orders response --- hyperliquid/client.go | 7 +- hyperliquid/consts.go | 6 +- hyperliquid/convert.go | 127 +++++++---- hyperliquid/convert_test.go | 111 ++++++++++ hyperliquid/exchange_service.go | 380 ++++++++++++++++---------------- hyperliquid/exchange_test.go | 297 +++++++++++++++++-------- hyperliquid/exchange_types.go | 28 +++ hyperliquid/hyperliquid_test.go | 41 +++- hyperliquid/info_test.go | 6 +- 9 files changed, 675 insertions(+), 328 deletions(-) create mode 100644 hyperliquid/convert_test.go diff --git a/hyperliquid/client.go b/hyperliquid/client.go index 5da47bc..7233069 100644 --- a/hyperliquid/client.go +++ b/hyperliquid/client.go @@ -51,9 +51,9 @@ func (client *Client) KeyManager() *PKeyManager { // getAPIURL returns the API URL based on the network type. func getURL(isMainnet bool) string { if isMainnet { - return "https://api.hyperliquid.xyz" + return MAINNET_API_URL } else { - return "https://api.hyperliquid-testnet.xyz" + return TESTNET_API_URL } } @@ -65,6 +65,7 @@ func NewClient(isMainnet bool) *Client { PadLevelText: true, }) logger.SetOutput(os.Stdout) + logger.SetLevel(log.DebugLevel) return &Client{ baseUrl: getURL(isMainnet), httpClient: http.DefaultClient, @@ -80,7 +81,7 @@ func NewClient(isMainnet bool) *Client { // debug prints the debug messages. func (client *Client) debug(format string, v ...interface{}) { if client.Debug { - client.Logger.Printf(format, v...) + client.Logger.Debugf(format, v...) } } diff --git a/hyperliquid/consts.go b/hyperliquid/consts.go index f51569f..85add4a 100644 --- a/hyperliquid/consts.go +++ b/hyperliquid/consts.go @@ -2,11 +2,15 @@ package hyperliquid const GLOBAL_DEBUG = false // Default debug that is used in all tests +// API constants +const MAINNET_API_URL = "https://api.hyperliquid.xyz" +const TESTNET_API_URL = "https://api.hyperliquid-testnet.xyz" + // Execution constants const DEFAULT_SLIPPAGE = 0.005 // 0.5% default slippage const SPOT_MAX_DECIMALS = 8 // Default decimals for spot const PERP_MAX_DECIMALS = 6 // Default decimals for perp -var SZ_DECIMALS = 2 // Default decimals for usdc +var USDC_SZ_DECIMALS = 2 // Default decimals for usdc that is used for withdraw // Signing constants const HYPERLIQUID_CHAIN_ID = 1337 diff --git a/hyperliquid/convert.go b/hyperliquid/convert.go index 911ccb3..be3d340 100644 --- a/hyperliquid/convert.go +++ b/hyperliquid/convert.go @@ -57,8 +57,8 @@ func OrderRequestToWire(req OrderRequest, meta map[string]AssetInfo, isSpot bool return OrderWire{ Asset: assetId, IsBuy: req.IsBuy, - LimitPx: RoundOrderPrice(req.LimitPx, info.SzDecimals, maxDecimals), - SizePx: RoundOrderSize(req.Sz, info.SzDecimals), + LimitPx: PriceToWire(req.LimitPx, maxDecimals, info.SzDecimals), + SizePx: SizeToWire(req.Sz, info.SzDecimals), ReduceOnly: req.ReduceOnly, OrderType: OrderTypeToWire(req.OrderType), Cloid: req.Cloid, @@ -81,8 +81,8 @@ func ModifyOrderRequestToWire(req ModifyOrderRequest, meta map[string]AssetInfo, Order: OrderWire{ Asset: assetId, IsBuy: req.IsBuy, - LimitPx: RoundOrderPrice(req.LimitPx, info.SzDecimals, maxDecimals), - SizePx: RoundOrderSize(req.Sz, info.SzDecimals), + LimitPx: PriceToWire(req.LimitPx, maxDecimals, info.SzDecimals), + SizePx: SizeToWire(req.Sz, info.SzDecimals), ReduceOnly: req.ReduceOnly, OrderType: OrderTypeToWire(req.OrderType), }, @@ -135,6 +135,90 @@ func FloatToWire(x float64, maxDecimals int, szDecimals int) string { return rounded } +// fastPow10 returns 10^exp as a float64. For our purposes exp is small. +func pow10(exp int) float64 { + var res float64 = 1 + for i := 0; i < exp; i++ { + res *= 10 + } + return res +} + +// PriceToWire converts a price value to its string representation per Hyperliquid rules. +// It enforces: +// - At most 5 significant figures, +// - And no more than (maxDecimals - szDecimals) decimal places. +// +// Integer prices are returned as is. +func PriceToWire(x float64, maxDecimals, szDecimals int) string { + // If the price is an integer, return it without decimals. + if x == math.Trunc(x) { + return strconv.FormatInt(int64(x), 10) + } + + // Rule 1: The tick rule – maximum decimals allowed is (maxDecimals - szDecimals). + allowedTick := maxDecimals - szDecimals + + // Rule 2: The significant figures rule – at most 5 significant digits. + var allowedSig int + if x >= 1 { + // Count digits in the integer part. + digits := int(math.Floor(math.Log10(x))) + 1 + allowedSig = 5 - digits + if allowedSig < 0 { + allowedSig = 0 + } + } else { + // For x < 1, determine the effective exponent. + exponent := int(math.Ceil(-math.Log10(x))) + allowedSig = 4 + exponent + } + + // Final allowed decimals is the minimum of the tick rule and the significant figures rule. + allowedDecimals := allowedTick + if allowedSig < allowedDecimals { + allowedDecimals = allowedSig + } + if allowedDecimals < 0 { + allowedDecimals = 0 + } + + // Round the price to allowedDecimals decimals. + factor := pow10(allowedDecimals) + rounded := math.Round(x*factor) / factor + + // Format the number with fixed precision. + s := strconv.FormatFloat(rounded, 'f', allowedDecimals, 64) + // Only trim trailing zeros if the formatted string contains a decimal point. + if strings.Contains(s, ".") { + s = strings.TrimRight(s, "0") + s = strings.TrimRight(s, ".") + } + return s +} + +// SizeToWire converts a size value to its string representation, +// rounding it to exactly szDecimals decimals. +// Integer sizes are returned without decimals. +func SizeToWire(x float64, szDecimals int) string { + // Return integer sizes without decimals. + if szDecimals == 0 { + return strconv.FormatInt(int64(x), 10) + } + // Return integer sizes directly. + if x == math.Trunc(x) { + return strconv.FormatInt(int64(x), 10) + } + + // Round the size value to szDecimals decimals. + factor := pow10(szDecimals) + rounded := math.Round(x*factor) / factor + + // Format with fixed precision then trim any trailing zeros and the decimal point. + s := strconv.FormatFloat(rounded, 'f', szDecimals, 64) + return strings.TrimRight(strings.TrimRight(s, "0"), ".") +} + // To sign raw messages via EIP-712 func StructToMap(strct any) (res map[string]interface{}, err error) { a, err := json.Marshal(strct) @@ -144,38 +228,3 @@ func StructToMap(strct any) (res map[string]interface{}, err error) { json.Unmarshal(a, &res) return res, nil } - -// Round the order size to the nearest tick size -func RoundOrderSize(x float64, szDecimals int) string { - newX := math.Round(x*math.Pow10(szDecimals)) / math.Pow10(szDecimals) - // TODO: add rounding - return big.NewFloat(newX).Text('f', szDecimals) -} - -// Round the order price to the nearest tick size -func RoundOrderPrice(x float64, szDecimals int, maxDecimals int) string { - maxSignFigures := 5 - allowedDecimals := maxDecimals - szDecimals - numberOfDigitsInIntegerPart := len(strconv.Itoa(int(x))) - if numberOfDigitsInIntegerPart >= maxSignFigures { - return RoundOrderSize(x, 0) - } - allowedSignFigures := maxSignFigures - numberOfDigitsInIntegerPart - if x < 1 { - text := RoundOrderSize(x, allowedDecimals) - startSignFigures := false - for i := 2; i < len(text); i++ { - if text[i] == '0' && !startSignFigures { - continue - } - startSignFigures = true - allowedSignFigures-- - if allowedSignFigures == 0 { - return text[:i+1] - } - } - return text - } else { - return RoundOrderSize(x, min(allowedSignFigures, allowedDecimals)) - } -} diff --git a/hyperliquid/convert_test.go b/hyperliquid/convert_test.go new file mode 100644 index 0000000..35f3451 --- /dev/null +++ b/hyperliquid/convert_test.go @@ -0,0 +1,111 @@ +package hyperliquid + +import ( + "testing" +) + +func TestConvert_SizeToWire(t *testing.T) { + testCases := []struct { + name string + input float64 + szDec int + expected string + }{ + { + name: "BTC Size", + input: 0.1, + szDec: 5, + expected: "0.1", + }, + { + name: "PNUT Size", + input: 101.22, + szDec: 1, + expected: "101.2", + }, + { + name: "ETH Size", + input: 0.1, + szDec: 4, + expected: "0.1", + }, + { + name: "ADA Size", + input: 100.123456, + szDec: 0, + expected: "100", + }, + { + name: "ETH Size", + input: 1.0, + szDec: 4, + expected: "1", + }, + { + name: "ETH Size", + input: 10.0, + szDec: 4, + expected: "10", + }, + { + name: "ETH Size", + input: 0.0100, + szDec: 4, + expected: "0.01", + }, + { + name: "ETH Size", + input: 0.010000001, + szDec: 4, + expected: "0.01", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + res := SizeToWire(tc.input, tc.szDec) + if res != tc.expected { + t.Errorf("SizeToWire() = %v, want %v", res, tc.expected) + } + }) + } +} + +func TestConvert_PriceToWire(t *testing.T) { + testCases := []struct { + name string + input float64 + maxDec int + szDec int + expected string + }{ + { + name: "BTC Price", + input: 105000, + maxDec: 6, + szDec: 5, + expected: "105000", + }, + { + name: "BTC Price", + input: 105000.1234, + maxDec: 6, + szDec: 5, + expected: "105000", + }, + { + name: "BTC Price", + input: 95001.123456, + maxDec: 6, + szDec: 5, + expected: "95001", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + res := PriceToWire(tc.input, tc.maxDec, tc.szDec) + if res != tc.expected { + t.Errorf("PriceToWire() = %v, want %v", res, tc.expected) + } + }) + } +} diff --git a/hyperliquid/exchange_service.go b/hyperliquid/exchange_service.go index 232b8ff..42587a1 100644 --- a/hyperliquid/exchange_service.go +++ b/hyperliquid/exchange_service.go @@ -67,6 +67,10 @@ func NewExchangeAPI(isMainnet bool) *ExchangeAPI { return &api } +// +// Helpers +// + func (api *ExchangeAPI) Endpoint() string { return api.baseEndpoint } @@ -92,141 +96,38 @@ func (api *ExchangeAPI) SlippagePriceSpot(coin string, isBuy bool, slippage floa return slippagePrice } -// Open a market order. -// Limit order with TIF=IOC and px=market price * (1 +- slippage). -// Size determines the amount of the coin to buy/sell. -// -// MarketOrder("BTC", 0.1, nil) // Buy 0.1 BTC -// MarketOrder("BTC", -0.1, nil) // Sell 0.1 BTC -// MarketOrder("BTC", 0.1, &slippage) // Buy 0.1 BTC with slippage -func (api *ExchangeAPI) MarketOrder(coin string, size float64, slippage *float64, clientOID ...string) (*OrderResponse, error) { - slpg := GetSlippage(slippage) - isBuy := IsBuy(size) - finalPx := api.SlippagePrice(coin, isBuy, slpg) - orderType := OrderType{ - Limit: &LimitOrderType{ - Tif: TifIoc, - }, - } - orderRequest := OrderRequest{ - Coin: coin, - IsBuy: isBuy, - Sz: math.Abs(size), - LimitPx: finalPx, - OrderType: orderType, - ReduceOnly: false, - } - if len(clientOID) > 0 { - orderRequest.Cloid = clientOID[0] - } - return api.Order(orderRequest, GroupingNa) -} - -// MarketOrderSpot is a market order for a spot coin. -// It is used to buy/sell a spot coin. -// Limit order with TIF=IOC and px=market price * (1 +- slippage). -// Size determines the amount of the coin to buy/sell. -// -// MarketOrderSpot("HYPE", 0.1, nil) // Buy 0.1 HYPE -// MarketOrderSpot("HYPE", -0.1, nil) // Sell 0.1 HYPE -// MarketOrderSpot("HYPE", 0.1, &slippage) // Buy 0.1 HYPE with slippage -func (api *ExchangeAPI) MarketOrderSpot(coin string, size float64, slippage *float64) (*OrderResponse, error) { - slpg := GetSlippage(slippage) - isBuy := IsBuy(size) - finalPx := api.SlippagePriceSpot(coin, isBuy, slpg) - orderType := OrderType{ - Limit: &LimitOrderType{ - Tif: TifIoc, - }, - } - orderRequest := OrderRequest{ - Coin: coin, - IsBuy: isBuy, - Sz: math.Abs(size), - LimitPx: finalPx, - OrderType: orderType, - ReduceOnly: false, +// Helper function to get the chain params based on the network type. +func (api *ExchangeAPI) getChainParams() (string, string) { + if api.IsMainnet() { + return "0xa4b1", "Mainnet" } - return api.OrderSpot(orderRequest, GroupingNa) + return "0x66eee", "Testnet" } -// Open a limit order. -// Order type can be Gtc, Ioc, Alo. -// Size determines the amount of the coin to buy/sell. -// See the constants TifGtc, TifIoc, TifAlo. -func (api *ExchangeAPI) LimitOrder(orderType string, coin string, size float64, px float64, reduceOnly bool, clientOID ...string) (*OrderResponse, error) { - // check if the order type is valid - if orderType != TifGtc && orderType != TifIoc && orderType != TifAlo { - return nil, APIError{Message: fmt.Sprintf("Invalid order type: %s. Available types: %s, %s, %s", orderType, TifGtc, TifIoc, TifAlo)} - } - orderTypeZ := OrderType{ - Limit: &LimitOrderType{ - Tif: orderType, - }, - } - orderRequest := OrderRequest{ - Coin: coin, - IsBuy: IsBuy(size), - Sz: math.Abs(size), - LimitPx: px, - OrderType: orderTypeZ, - ReduceOnly: reduceOnly, - } - if len(clientOID) > 0 { - orderRequest.Cloid = clientOID[0] +// Build bulk orders EIP712 message +func (api *ExchangeAPI) BuildBulkOrdersEIP712(requests []OrderRequest, grouping Grouping) (apitypes.TypedData, error) { + var wires []OrderWire + for _, req := range requests { + wires = append(wires, OrderRequestToWire(req, api.meta, false)) } - return api.Order(orderRequest, GroupingNa) -} - -// Close all positions for a given coin. They are closing with a market order. -func (api *ExchangeAPI) ClosePosition(coin string) (*OrderResponse, error) { - // Get all positions and find the one for the coin - // Then just make MarketOpen with the reverse size - state, err := api.infoAPI.GetUserState(api.AccountAddress()) + timestamp := GetNonce() + action := OrderWiresToOrderAction(wires, grouping) + srequest, err := api.BuildEIP712Message(action, timestamp) if err != nil { - api.debug("Error GetUserState: %s", err) - return nil, err - } - positions := state.AssetPositions - slippage := GetSlippage(nil) - - // Find the position for the coin - for _, position := range positions { - item := position.Position - if coin != item.Coin { - continue - } - size := item.Szi - // reverse the position to close - isBuy := !IsBuy(size) - finalPx := api.SlippagePrice(coin, isBuy, slippage) - orderType := OrderType{ - Limit: &LimitOrderType{ - Tif: "Ioc", - }, - } - orderRequest := OrderRequest{ - Coin: coin, - IsBuy: isBuy, - Sz: math.Abs(size), - LimitPx: finalPx, - OrderType: orderType, - ReduceOnly: true, - } - return api.Order(orderRequest, GroupingNa) + api.debug("Error building EIP712 message: %s", err) + return apitypes.TypedData{}, err } - return nil, APIError{Message: fmt.Sprintf("No position found for %s", coin)} + return SignRequestToEIP712TypedData(srequest), nil } -// Place single order -func (api *ExchangeAPI) Order(request OrderRequest, grouping Grouping) (*OrderResponse, error) { - return api.BulkOrders([]OrderRequest{request}, grouping, false) +// Build order EIP712 message +func (api *ExchangeAPI) BuildOrderEIP712(request OrderRequest, grouping Grouping) (apitypes.TypedData, error) { + return api.BuildBulkOrdersEIP712([]OrderRequest{request}, grouping) } -// OrderSpot places a spot order -func (api *ExchangeAPI) OrderSpot(request OrderRequest, grouping Grouping) (*OrderResponse, error) { - return api.BulkOrders([]OrderRequest{request}, grouping, true) -} +// +// Base Methods +// // Place orders in bulk // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint#place-an-order @@ -306,11 +207,6 @@ func (api *ExchangeAPI) BulkModifyOrders(modifyRequests []ModifyOrderRequest, is return MakeUniversalRequest[OrderResponse](api, request) } -// Cancel exact order by OID -func (api *ExchangeAPI) CancelOrderByOID(coin string, orderID int64) (*OrderResponse, error) { - return api.BulkCancelOrders([]CancelOidWire{{Asset: api.meta[coin].AssetId, Oid: int(orderID)}}) -} - // Cancel exact order by Client Order Id // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint#cancel-order-s-by-cloid func (api *ExchangeAPI) CancelOrderByCloid(coin string, clientOID string) (*OrderResponse, error) { @@ -338,40 +234,6 @@ func (api *ExchangeAPI) CancelOrderByCloid(coin string, clientOID string) (*Orde return MakeUniversalRequest[OrderResponse](api, request) } -// Cancel all orders for a given coin -func (api *ExchangeAPI) CancelAllOrdersByCoin(coin string) (*OrderResponse, error) { - orders, err := api.infoAPI.GetOpenOrders(api.AccountAddress()) - if err != nil { - api.debug("Error getting orders: %s", err) - return nil, err - } - var cancels []CancelOidWire - for _, order := range *orders { - if coin != order.Coin { - continue - } - cancels = append(cancels, CancelOidWire{Asset: api.meta[coin].AssetId, Oid: int(order.Oid)}) - } - return api.BulkCancelOrders(cancels) -} - -// Cancel all open orders -func (api *ExchangeAPI) CancelAllOrders() (*OrderResponse, error) { - orders, err := api.infoAPI.GetOpenOrders(api.AccountAddress()) - if err != nil { - api.debug("Error getting orders: %s", err) - return nil, err - } - if len(*orders) == 0 { - return nil, APIError{Message: "No open orders to cancel"} - } - var cancels []CancelOidWire - for _, order := range *orders { - cancels = append(cancels, CancelOidWire{Asset: api.meta[order.Coin].AssetId, Oid: int(order.Oid)}) - } - return api.BulkCancelOrders(cancels) -} - // Update leverage for a coin // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint#update-leverage func (api *ExchangeAPI) UpdateLeverage(coin string, isCross bool, leverage int) (*DefaultExchangeResponse, error) { @@ -403,7 +265,7 @@ func (api *ExchangeAPI) Withdraw(destination string, amount float64) (*WithdrawR action := WithdrawAction{ Type: "withdraw3", Destination: destination, - Amount: FloatToWire(amount, PERP_MAX_DECIMALS, SZ_DECIMALS), + Amount: SizeToWire(amount, USDC_SZ_DECIMALS), Time: nonce, } signatureChainID, chainType := api.getChainParams() @@ -423,31 +285,181 @@ func (api *ExchangeAPI) Withdraw(destination string, amount float64) (*WithdrawR return MakeUniversalRequest[WithdrawResponse](api, request) } -// Helper function to get the chain params based on the network type. -func (api *ExchangeAPI) getChainParams() (string, string) { - if api.IsMainnet() { - return "0xa4b1", "Mainnet" +// +// Connectors Methods +// + +// Place single order +func (api *ExchangeAPI) Order(request OrderRequest, grouping Grouping) (*OrderResponse, error) { + return api.BulkOrders([]OrderRequest{request}, grouping, false) +} + +// Open a market order. +// Limit order with TIF=IOC and px=market price * (1 +- slippage). +// Size determines the amount of the coin to buy/sell. +// +// MarketOrder("BTC", 0.1, nil) // Buy 0.1 BTC +// MarketOrder("BTC", -0.1, nil) // Sell 0.1 BTC +// MarketOrder("BTC", 0.1, &slippage) // Buy 0.1 BTC with slippage +func (api *ExchangeAPI) MarketOrder(coin string, size float64, slippage *float64, clientOID ...string) (*OrderResponse, error) { + slpg := GetSlippage(slippage) + isBuy := IsBuy(size) + finalPx := api.SlippagePrice(coin, isBuy, slpg) + orderType := OrderType{ + Limit: &LimitOrderType{ + Tif: TifIoc, + }, } - return "0x66eee", "Testnet" + orderRequest := OrderRequest{ + Coin: coin, + IsBuy: isBuy, + Sz: math.Abs(size), + LimitPx: finalPx, + OrderType: orderType, + ReduceOnly: false, + } + if len(clientOID) > 0 { + orderRequest.Cloid = clientOID[0] + } + return api.Order(orderRequest, GroupingNa) } -// Build bulk orders EIP712 message -func (api *ExchangeAPI) BuildBulkOrdersEIP712(requests []OrderRequest, grouping Grouping) (apitypes.TypedData, error) { - var wires []OrderWire - for _, req := range requests { - wires = append(wires, OrderRequestToWire(req, api.meta, false)) +// MarketOrderSpot is a market order for a spot coin. +// It is used to buy/sell a spot coin. +// Limit order with TIF=IOC and px=market price * (1 +- slippage). +// Size determines the amount of the coin to buy/sell. +// +// MarketOrderSpot("HYPE", 0.1, nil) // Buy 0.1 HYPE +// MarketOrderSpot("HYPE", -0.1, nil) // Sell 0.1 HYPE +// MarketOrderSpot("HYPE", 0.1, &slippage) // Buy 0.1 HYPE with slippage +func (api *ExchangeAPI) MarketOrderSpot(coin string, size float64, slippage *float64) (*OrderResponse, error) { + slpg := GetSlippage(slippage) + isBuy := IsBuy(size) + finalPx := api.SlippagePriceSpot(coin, isBuy, slpg) + orderType := OrderType{ + Limit: &LimitOrderType{ + Tif: TifIoc, + }, } - timestamp := GetNonce() - action := OrderWiresToOrderAction(wires, grouping) - srequest, err := api.BuildEIP712Message(action, timestamp) + orderRequest := OrderRequest{ + Coin: coin, + IsBuy: isBuy, + Sz: math.Abs(size), + LimitPx: finalPx, + OrderType: orderType, + ReduceOnly: false, + } + return api.OrderSpot(orderRequest, GroupingNa) +} + +// Open a limit order. +// Order type can be Gtc, Ioc, Alo. +// Size determines the amount of the coin to buy/sell. +// See the constants TifGtc, TifIoc, TifAlo. +func (api *ExchangeAPI) LimitOrder(orderType string, coin string, size float64, px float64, reduceOnly bool, clientOID ...string) (*OrderResponse, error) { + // check if the order type is valid + if orderType != TifGtc && orderType != TifIoc && orderType != TifAlo { + return nil, APIError{Message: fmt.Sprintf("Invalid order type: %s. Available types: %s, %s, %s", orderType, TifGtc, TifIoc, TifAlo)} + } + orderTypeZ := OrderType{ + Limit: &LimitOrderType{ + Tif: orderType, + }, + } + orderRequest := OrderRequest{ + Coin: coin, + IsBuy: IsBuy(size), + Sz: math.Abs(size), + LimitPx: px, + OrderType: orderTypeZ, + ReduceOnly: reduceOnly, + } + if len(clientOID) > 0 { + orderRequest.Cloid = clientOID[0] + } + return api.Order(orderRequest, GroupingNa) +} + +// Close all positions for a given coin. They are closing with a market order. +func (api *ExchangeAPI) ClosePosition(coin string) (*OrderResponse, error) { + // Get all positions and find the one for the coin + // Then just make MarketOpen with the reverse size + state, err := api.infoAPI.GetUserState(api.AccountAddress()) if err != nil { - api.debug("Error building EIP712 message: %s", err) - return apitypes.TypedData{}, err + api.debug("Error GetUserState: %s", err) + return nil, err } - return SignRequestToEIP712TypedData(srequest), nil + positions := state.AssetPositions + slippage := GetSlippage(nil) + + // Find the position for the coin + for _, position := range positions { + item := position.Position + if coin != item.Coin { + continue + } + size := item.Szi + // reverse the position to close + isBuy := !IsBuy(size) + finalPx := api.SlippagePrice(coin, isBuy, slippage) + orderType := OrderType{ + Limit: &LimitOrderType{ + Tif: "Ioc", + }, + } + orderRequest := OrderRequest{ + Coin: coin, + IsBuy: isBuy, + Sz: math.Abs(size), + LimitPx: finalPx, + OrderType: orderType, + ReduceOnly: true, + } + return api.Order(orderRequest, GroupingNa) + } + return nil, APIError{Message: fmt.Sprintf("No position found for %s", coin)} } -// Build order EIP712 message -func (api *ExchangeAPI) BuildOrderEIP712(request OrderRequest, grouping Grouping) (apitypes.TypedData, error) { - return api.BuildBulkOrdersEIP712([]OrderRequest{request}, grouping) +// OrderSpot places a spot order +func (api *ExchangeAPI) OrderSpot(request OrderRequest, grouping Grouping) (*OrderResponse, error) { + return api.BulkOrders([]OrderRequest{request}, grouping, true) +} + +// Cancel exact order by OID +func (api *ExchangeAPI) CancelOrderByOID(coin string, orderID int64) (*OrderResponse, error) { + return api.BulkCancelOrders([]CancelOidWire{{Asset: api.meta[coin].AssetId, Oid: int(orderID)}}) +} + +// Cancel all orders for a given coin +func (api *ExchangeAPI) CancelAllOrdersByCoin(coin string) (*OrderResponse, error) { + orders, err := api.infoAPI.GetOpenOrders(api.AccountAddress()) + if err != nil { + api.debug("Error getting orders: %s", err) + return nil, err + } + var cancels []CancelOidWire + for _, order := range *orders { + if coin != order.Coin { + continue + } + cancels = append(cancels, CancelOidWire{Asset: api.meta[coin].AssetId, Oid: int(order.Oid)}) + } + return api.BulkCancelOrders(cancels) +} + +// Cancel all open orders +func (api *ExchangeAPI) CancelAllOrders() (*OrderResponse, error) { + orders, err := api.infoAPI.GetOpenOrders(api.AccountAddress()) + if err != nil { + api.debug("Error getting orders: %s", err) + return nil, err + } + if len(*orders) == 0 { + return nil, APIError{Message: "No open orders to cancel"} + } + var cancels []CancelOidWire + for _, order := range *orders { + cancels = append(cancels, CancelOidWire{Asset: api.meta[order.Coin].AssetId, Oid: int(order.Oid)}) + } + return api.BulkCancelOrders(cancels) } diff --git a/hyperliquid/exchange_test.go b/hyperliquid/exchange_test.go index 3581ec6..49abdab 100644 --- a/hyperliquid/exchange_test.go +++ b/hyperliquid/exchange_test.go @@ -9,7 +9,7 @@ import ( ) func GetExchangeAPI() *ExchangeAPI { - exchangeAPI := NewExchangeAPI(true) + exchangeAPI := NewExchangeAPI(false) if GLOBAL_DEBUG { exchangeAPI.SetDebugActive() } @@ -23,68 +23,80 @@ func GetExchangeAPI() *ExchangeAPI { return exchangeAPI } -func TestExchangeAPI_Endpoint(testing *testing.T) { +func TestExchangeAPI_Endpoint(t *testing.T) { exchangeAPI := GetExchangeAPI() res := exchangeAPI.Endpoint() if res != "/exchange" { - testing.Errorf("Endpoint() = %v, want %v", res, "/exchange") + t.Errorf("Endpoint() = %v, want %v", res, "/exchange") } } -func TestExchangeAPI_AccountAddress(testing *testing.T) { +func TestExchangeAPI_AccountAddress(t *testing.T) { exchangeAPI := GetExchangeAPI() res := exchangeAPI.AccountAddress() TARGET_ADDRESS := os.Getenv("TEST_ADDRESS") if res != TARGET_ADDRESS { - testing.Errorf("AccountAddress() = %v, want %v", res, TARGET_ADDRESS) + t.Errorf("AccountAddress() = %v, want %v", res, TARGET_ADDRESS) } } -func TestExchangeAPI_isMainnet(testing *testing.T) { +func TestExchangeAPI_isMainnet(t *testing.T) { exchangeAPI := GetExchangeAPI() res := exchangeAPI.IsMainnet() - if res != true { - testing.Errorf("isMainnet() = %v, want %v", res, true) + if res != false { + t.Errorf("isMainnet() = %v, want %v", res, true) } } -func TestExchangeAPI_UpdateLeverage(testing *testing.T) { +func TestExchageAPI_TestMetaIsNotEmpty(t *testing.T) { + exchangeAPI := GetExchangeAPI() + meta := exchangeAPI.meta + if meta == nil { + t.Errorf("Meta() = %v, want not nil", meta) + } + if len(meta) == 0 { + t.Errorf("Meta() = %v, want not empty", meta) + } + t.Logf("Meta() = %+v", meta) +} + +func TestExchangeAPI_UpdateLeverage(t *testing.T) { exchangeAPI := GetExchangeAPI() _, err := exchangeAPI.UpdateLeverage("ETH", true, 20) if err != nil { - testing.Errorf("UpdateLeverage() error = %v", err) + t.Errorf("UpdateLeverage() error = %v", err) } // Set incorrect leverage 2000 _, err = exchangeAPI.UpdateLeverage("ETH", true, 2000) if err == nil { - testing.Errorf("UpdateLeverage() error = %v", err) + t.Errorf("UpdateLeverage() error = %v", err) } else if err.Error() != "Invalid leverage value" { - testing.Errorf("UpdateLeverage() error = %v expected Invalid leverage value", err) + t.Errorf("UpdateLeverage() error = %v expected Invalid leverage value", err) } - testing.Logf("UpdateLeverage() = %v", err) + t.Logf("UpdateLeverage() = %v", err) } -func TestExchangeAPI_MarketOpen(testing *testing.T) { +func TestExchangeAPI_MarketOpen(t *testing.T) { exchangeAPI := GetExchangeAPI() size := -0.01 coin := "ETH" res, err := exchangeAPI.MarketOrder(coin, size, nil) if err != nil { - testing.Errorf("MakeOpen() error = %v", err) + t.Errorf("MakeOpen() error = %v", err) } - testing.Logf("MakeOpen() = %v", res) + t.Logf("MakeOpen() = %v", res) avgPrice := res.Response.Data.Statuses[0].Filled.AvgPx if avgPrice == 0 { - testing.Errorf("res.Response.Data.Statuses[0].Filled.AvgPx = %v", avgPrice) + t.Errorf("res.Response.Data.Statuses[0].Filled.AvgPx = %v", avgPrice) } totalSize := res.Response.Data.Statuses[0].Filled.TotalSz if totalSize != math.Abs(size) { - testing.Errorf("res.Response.Data.Statuses[0].Filled.TotalSz = %v", totalSize) + t.Errorf("res.Response.Data.Statuses[0].Filled.TotalSz = %v", totalSize) } time.Sleep(2 * time.Second) // wait to execute order accountState, err := exchangeAPI.infoAPI.GetUserState(exchangeAPI.AccountAddress()) if err != nil { - testing.Errorf("GetAccountState() error = %v", err) + t.Errorf("GetAccountState() error = %v", err) } positionOpened := false positionCorrect := false @@ -97,109 +109,116 @@ func TestExchangeAPI_MarketOpen(testing *testing.T) { } } if !positionOpened { - testing.Errorf("Position not found: %v", accountState.AssetPositions) + t.Errorf("Position not found: %v", accountState.AssetPositions) } if !positionCorrect { - testing.Errorf("Position not correct: %v", accountState.AssetPositions) + t.Errorf("Position not correct: %v", accountState.AssetPositions) } - testing.Logf("GetAccountState() = %v", accountState) + t.Logf("GetAccountState() = %v", accountState) time.Sleep(5 * time.Second) // wait to execute order } -func TestExchangeAPI_LimitOrderAndCancel(testing *testing.T) { +func TestExchangeAPI_MarketClose(t *testing.T) { exchangeAPI := GetExchangeAPI() - size := 0.01 - coin := "ETH" - px := 2000.0 + res, err := exchangeAPI.ClosePosition("ETH") + if err != nil { + t.Errorf("MakeClose() error = %v", err) + } + t.Logf("MakeClose() = %v", res) +} + +func TestExchangeAPI_LimitOrder(t *testing.T) { + exchangeAPI := GetExchangeAPI() + size := 100.1234 + coin := "PNUT" + px := 0.154956 res, err := exchangeAPI.LimitOrder(TifGtc, coin, size, px, false) if err != nil { - testing.Errorf("MakeLimit() error = %v", err) + t.Errorf("MakeLimit() error = %v", err) } - testing.Logf("MakeLimit() = %v", res) + t.Logf("MakeLimit() = %v", res) +} + +func TestExchangeAPI_CancelAllOrders(t *testing.T) { + exchangeAPI := GetExchangeAPI() + res, err := exchangeAPI.CancelAllOrders() + if err != nil { + t.Errorf("CancelAllOrders() error = %v", err) + } + t.Logf("CancelAllOrders() = %v", res) +} + +func TestExchangeAPI_CreateLimitOrderAndCancelOrderByCloidt(t *testing.T) { + exchangeAPI := GetExchangeAPI() + size := -0.01 + coin := "BTC" + px := 105000.0 + cloid := "0x1234567890abcdef1234567890abcdef" + res, err := exchangeAPI.LimitOrder(TifGtc, coin, size, px, false, cloid) + if err != nil { + t.Errorf("MakeLimit() error = %v", err) + } + t.Logf("MakeLimit() = %v", res) openOrders, err := exchangeAPI.infoAPI.GetOpenOrders(exchangeAPI.AccountAddress()) if err != nil { - testing.Errorf("GetAccountOpenOrders() error = %v", err) + t.Errorf("GetAccountOpenOrders() error = %v", err) } - testing.Logf("GetAccountOpenOrders() = %v", openOrders) + t.Logf("GetAccountOpenOrders() = %v", openOrders) orderOpened := false + var orderCloid string for _, order := range *openOrders { - if order.Coin == coin && order.Sz == size && order.LimitPx == px { + t.Logf("Order: %+v", order) + if order.Coin == coin && order.Sz == -size && order.LimitPx == px { orderOpened = true + orderCloid = order.Cloid break } } if !orderOpened { - testing.Errorf("Order not found: %v", openOrders) + t.Errorf("Order not found: %v", openOrders) } time.Sleep(5 * time.Second) // wait to execute order - cancelRes, err := exchangeAPI.CancelAllOrders() - if err != nil { - testing.Errorf("CancelAllOrders() error = %v", err) - } - testing.Logf("CancelAllOrders() = %v", cancelRes) -} - -func TestExchangeAPI_CancelAllOrders(testing *testing.T) { - exchangeAPI := GetExchangeAPI() - res, err := exchangeAPI.CancelAllOrders() - if err != nil { - testing.Errorf("CancelAllOrders() error = %v", err) - } - testing.Logf("CancelAllOrders() = %v", res) -} - -func TestExchangeAPI_MarketClose(testing *testing.T) { - exchangeAPI := GetExchangeAPI() - res, err := exchangeAPI.ClosePosition("ETH") + cancelRes, err := exchangeAPI.CancelOrderByCloid(coin, orderCloid) if err != nil { - testing.Errorf("MakeClose() error = %v", err) + t.Errorf("CancelOrderByCloid() error = %v", err) } - testing.Logf("MakeClose() = %v", res) + t.Logf("CancelOrderByCloid() = %v", cancelRes) } -func TestExchangeAPI_TestWithdraw(testing *testing.T) { +func TestExchangeAPI_CreateLimitOrderAndCancelOrderByOid(t *testing.T) { exchangeAPI := GetExchangeAPI() - withdrawAmount := 10.0 - stateBefore, err := exchangeAPI.infoAPI.GetUserState(exchangeAPI.AccountAddress()) + size := 0.1 + coin := "BTC" + px := 85000.0 + res, err := exchangeAPI.LimitOrder(TifGtc, coin, size, px, false) if err != nil { - testing.Errorf("GetAccountState() error = %v", err) - } - testing.Logf("GetAccountState() = %v", stateBefore) - balanceBefore := stateBefore.Withdrawable - if balanceBefore < withdrawAmount { - testing.Errorf("Insufficient balance: %v", stateBefore) + t.Errorf("MakeLimit() error = %v", err) } - accountAddress := exchangeAPI.AccountAddress() // withdraw to the same address - res, err := exchangeAPI.Withdraw(accountAddress, withdrawAmount) + t.Logf("MakeLimit() = %v", res) + openOrders, err := exchangeAPI.infoAPI.GetOpenOrders(exchangeAPI.AccountAddress()) if err != nil { - testing.Errorf("Withdraw() error = %v", err) + t.Errorf("GetAccountOpenOrders() error = %v", err) } - testing.Logf("Withdraw() = %v", res) - time.Sleep(30 * time.Second) // wait to execute order - stateAfter, err := exchangeAPI.infoAPI.GetUserState(exchangeAPI.AccountAddress()) - if err != nil { - testing.Errorf("GetAccountState() error = %v", err) + t.Logf("GetAccountOpenOrders() = %v", openOrders) + orderOpened := false + var orderOid int64 + for _, order := range *openOrders { + t.Logf("Order: %+v", order) + if order.Coin == coin && order.Sz == size && order.LimitPx == px { + orderOpened = true + orderOid = order.Oid + break + } } - testing.Logf("GetAccountState() = %v", stateAfter) - balanceAfter := stateAfter.Withdrawable - if balanceAfter >= balanceBefore { - testing.Errorf("Balance not updated: %v", stateAfter) + if !orderOpened { + t.Errorf("Order not found: %v", openOrders) } -} - -func TestExchageAPI_TestMarketOrderSpot(testing *testing.T) { - exchangeAPI := GetExchangeAPI() - size := 1600.0 - coin := "YEETI" - res, err := exchangeAPI.MarketOrderSpot(coin, size, nil) + time.Sleep(5 * time.Second) // wait to execute order + cancelRes, err := exchangeAPI.CancelOrderByOID(coin, orderOid) if err != nil { - testing.Errorf("MakeOpen() error = %v", err) - } - testing.Logf("MakeOpen() = %v", res) - avgPrice := res.Response.Data.Statuses[0].Filled.AvgPx - if avgPrice == 0 { - testing.Errorf("res.Response.Data.Statuses[0].Filled.AvgPx = %v", avgPrice) + t.Errorf("CancelOrderByOid() error = %v", err) } + t.Logf("CancelOrderByOid() = %v", cancelRes) } func TestExchangeAPI_TestModifyOrder(t *testing.T) { @@ -257,3 +276,107 @@ func TestExchangeAPI_TestModifyOrder(t *testing.T) { } t.Logf("CancelAllOrders() = %v", cancelRes) } + +func TestExchangeAPI_TestMultipleMarketOrder(t *testing.T) { + exchangeAPI := GetExchangeAPI() + testCases := []struct { + coin string + size float64 + }{ + {"BTC", 0.001}, + {"BTC", -0.001}, + {"ETH", 0.12}, + {"ETH", -0.12}, + {"INJ", 21.1}, + {"INJ", -21.1}, + {"PNUT", 100.122}, + {"PNUT", -100.1}, + {"ADA", 100.123456}, + {"ADA", -100.123456}, + } + for _, tc := range testCases { + t.Run(tc.coin, func(t *testing.T) { + res, err := exchangeAPI.MarketOrder(tc.coin, tc.size, nil) + if err != nil { + t.Errorf("MarketOrder() error = %v", err) + } + t.Logf("MarketOrder() = %v", res) + }) + + } +} + +func TestExchangeAPI_TestIncorrectOrderSize(t *testing.T) { + exchangeAPI := GetExchangeAPI() + size := 0.1 + coin := "ADA" + res, err := exchangeAPI.MarketOrder(coin, size, nil) + if err != nil { + t.Errorf("MarketOrder() error = %v", err) + } + if res.Response.Data.Statuses[0].Error != "Order has zero size." { + t.Errorf("MarketOrder() error = %s but expected %s", res.Response.Data.Statuses[0].Error, "Order has zero size.") + } +} + +func TestExchangeAPI_TestClosePositionByMarket(t *testing.T) { + exchangeAPI := GetExchangeAPI() + size := -1.0 + coin := "ETH" + res, err := exchangeAPI.MarketOrder(coin, size, nil) + if err != nil { + t.Errorf("MarketOrder() error = %v", err) + } + t.Logf("MarketOrder() = %v", res) + time.Sleep(5 * time.Second) + // close position with big size + closeRes, err := exchangeAPI.MarketOrder(coin, -size, nil) + if err != nil { + t.Errorf("MarketOrder() error = %v", err) + } + t.Logf("MarketOrder() = %v", closeRes) + accountState, err := exchangeAPI.infoAPI.GetUserState(exchangeAPI.AccountAddress()) + if err != nil { + t.Errorf("GetAccountState() error = %v", err) + } + // check that there is no opened position + if len(accountState.AssetPositions) != 0 { + t.Errorf("Account has opened positions: %v", accountState.AssetPositions) + } +} + +// Test Mainnet Only +func TestExchangeAPI_TestWithdraw(t *testing.T) { + exchangeAPI := GetExchangeAPI() + withdrawAmount := 20.0 + stateBefore, err := exchangeAPI.infoAPI.GetUserState(exchangeAPI.AccountAddress()) + if err != nil { + t.Errorf("GetAccountState() error = %v", err) + } + t.Logf("GetAccountState() = %v", stateBefore) + balanceBefore := stateBefore.Withdrawable + if balanceBefore < withdrawAmount { + t.Errorf("Insufficient balance: %v", stateBefore) + } + accountAddress := exchangeAPI.AccountAddress() // withdraw to the same address + res, err := exchangeAPI.Withdraw(accountAddress, withdrawAmount) + if err != nil { + t.Errorf("Withdraw() error = %v", err) + } + t.Logf("Withdraw() = %v", res) +} + +func TestExchageAPI_TestMarketOrderSpot(t *testing.T) { + exchangeAPI := GetExchangeAPI() + size := 0.81 + coin := "HYPE" + res, err := exchangeAPI.MarketOrderSpot(coin, size, nil) + if err != nil { + t.Errorf("MakeOpen() error = %v", err) + } + t.Logf("MakeOpen() = %v", res) + avgPrice := res.Response.Data.Statuses[0].Filled.AvgPx + if avgPrice == 0 { + t.Errorf("res.Response.Data.Statuses[0].Filled.AvgPx = %v", avgPrice) + } +} diff --git a/hyperliquid/exchange_types.go b/hyperliquid/exchange_types.go index e83c9a8..e2e621a 100644 --- a/hyperliquid/exchange_types.go +++ b/hyperliquid/exchange_types.go @@ -1,5 +1,10 @@ package hyperliquid +import ( + "encoding/json" + "fmt" +) + type RsvSignature struct { R string `json:"r"` S string `json:"s"` @@ -129,6 +134,29 @@ type StatusResponse struct { Resting RestingStatus `json:"resting,omitempty"` Filled FilledStatus `json:"filled,omitempty"` Error string `json:"error,omitempty"` + Status string `json:"status,omitempty"` +} + +// UnmarshalJSON implements custom unmarshaling for StatusResponse. +// It first checks if the incoming JSON is a simple string. If so, it assigns the +// value to the Status field. Otherwise, it unmarshals the JSON into the struct normally. +func (sr *StatusResponse) UnmarshalJSON(data []byte) error { + // Try to unmarshal data as a string. + var s string + if err := json.Unmarshal(data, &s); err == nil { + sr.Status = s + return nil + } + + // Otherwise, unmarshal as a full object. + // Use an alias to avoid infinite recursion. + type Alias StatusResponse + var alias Alias + if err := json.Unmarshal(data, &alias); err != nil { + return fmt.Errorf("StatusResponse: unable to unmarshal data as string or object: %w", err) + } + *sr = StatusResponse(alias) + return nil } type CancelRequest struct { diff --git a/hyperliquid/hyperliquid_test.go b/hyperliquid/hyperliquid_test.go index 4ac10d4..6ba58e9 100644 --- a/hyperliquid/hyperliquid_test.go +++ b/hyperliquid/hyperliquid_test.go @@ -25,27 +25,32 @@ func TestHyperliquid_CheckFieldsConsistency(t *testing.T) { if hl.InfoAPI.baseEndpoint != "/info" { t.Errorf("baseEndpoint = %v, want %v", hl.InfoAPI.baseEndpoint, "/info") } - if hl.InfoAPI.baseUrl != "https://api.hyperliquid.xyz" { - t.Errorf("baseUrl = %v, want %v", hl.InfoAPI.baseUrl, "https://api.hyperliquid.com") + var apiUrl string + if hl.ExchangeAPI.IsMainnet() { + apiUrl = MAINNET_API_URL + } else { + apiUrl = TESTNET_API_URL + } + if hl.InfoAPI.baseUrl != apiUrl { + t.Errorf("baseUrl = %v, want %v", hl.InfoAPI.baseUrl, apiUrl) } hl.SetDebugActive() if hl.InfoAPI.Debug != hl.ExchangeAPI.Debug { t.Errorf("debug = %v, want %v", hl.InfoAPI.Debug, hl.ExchangeAPI.Debug) } + savedAddress := hl.AccountAddress() newAddress := "0x1234567890" hl.SetAccountAddress(newAddress) if hl.InfoAPI.AccountAddress() != newAddress { - t.Errorf("AccountAddress = %v, want %v", hl.InfoAPI.AccountAddress(), newAddress) + t.Errorf("InfoAPI.AccountAddress = %v, want %v", hl.InfoAPI.AccountAddress(), newAddress) } if hl.ExchangeAPI.AccountAddress() != newAddress { - t.Errorf("AccountAddress = %v, want %v", hl.ExchangeAPI.AccountAddress(), newAddress) + t.Errorf("ExchangeAPI.AccountAddress = %v, want %v", hl.ExchangeAPI.AccountAddress(), newAddress) } if hl.AccountAddress() != newAddress { - t.Errorf("AccountAddress = %v, want %v", hl.AccountAddress(), newAddress) - } - if hl.infoAPI.AccountAddress() != newAddress { - t.Errorf("AccountAddress = %v, want %v", hl.infoAPI.AccountAddress(), newAddress) + t.Errorf("gl.AccountAddress = %v, want %v", hl.AccountAddress(), newAddress) } + hl.SetAccountAddress(savedAddress) } func TestHyperliquid_MakeSomeTradingLogic(t *testing.T) { @@ -114,11 +119,25 @@ func TestHyperliquid_MakeSomeTradingLogic(t *testing.T) { t.Logf("GetAccountState(): %v", res9) } -func TestHyperliquid_MakeOrder(t *testing.T) { +func TestHyperliquid_MarketOrder(t *testing.T) { client := GetHyperliquidAPI() - order, err := client.MarketOrder("ADA", 15, nil) + order, err := client.MarketOrder("ADA", 100, nil) + if err != nil { + t.Errorf("Error: %v", err) + } + t.Logf("MarketOrder(ADA, 100, nil): %+v", order) +} + +func TestHyperliquid_LimitOrder(t *testing.T) { + client := GetHyperliquidAPI() + order1, err := client.LimitOrder(TifGtc, "BTC", 0.01, 70000, false) + if err != nil { + t.Errorf("Error: %v", err) + } + t.Logf("LimitOrder(TifGtc, BTC, 0.01, 70000, false): %+v", order1) + order2, err := client.LimitOrder(TifGtc, "BTC", -0.01, 120000, false) if err != nil { t.Errorf("Error: %v", err) } - t.Logf("MarketOrder(ADA, 15, nil): %+v", order) + t.Logf("LimitOrder(TifGtc, BTC, -0.01, 120000, false): %+v", order2) } diff --git a/hyperliquid/info_test.go b/hyperliquid/info_test.go index 0df0f91..99df654 100644 --- a/hyperliquid/info_test.go +++ b/hyperliquid/info_test.go @@ -6,7 +6,7 @@ import ( ) func GetInfoAPI() *InfoAPI { - api := NewInfoAPI(true) + api := NewInfoAPI(false) if GLOBAL_DEBUG { api.SetDebugActive() } @@ -128,8 +128,8 @@ func TestInfoAPI_GetMeta(t *testing.T) { t.Errorf("GetMeta() error = %v", err) } t.Logf("GetMeta() = %v", res) - if res.Universe[0].Name != "BTC" { - t.Errorf("GetMeta() doesnt return %v, want %v", res.Universe[0].Name, "BTC") + if res.Universe[0].Name != "SOL" { + t.Errorf("GetMeta() doesnt return %v, want %v", res.Universe[0].Name, "SOL") } }