Back to Blog

Building a Hyperliquid Client in Python – Part II

This hands-on continuation of the Hyperliquid client series focuses on implementing and using public /info endpoints. It details key data retrieval functions such as funding rates, candlesticks, and order books, and establishes a foundation for robust, production-ready API interactions.

Posted by

Hyperliquid client

Part I introduced how to build a Hyperliquid client.

Part II focuses on my most-used public (no user address or vault address needed) requests to the /info endpoint. For comprehensive details about Hyperliquid’s features, see the full API documentation: https://hyperliquid.gitbook.io/hyperliquid-docs.

For simplicity, the HyperliquidClient example includes minimal error handling. In practice, robust failure management is essential. APIs may return unexpected data, rate-limit notices, or experience connection issues. In live trading, overlooking these risks can result in missed trades or unintended positions. A production client should implement retries, timeouts, and logging to ensure reliability.

Disclaimer: I am a researcher, not a professional software developer. The code is functional and well-tested for my use, but it is not production-ready. Please use it as a learning resource or as a foundation for your own projects

Trading futures involves significant risk, and losses may exceed your margin. This material is provided for educational purposes only.

Hyperliquid API Endpoints

One of the things that makes Hyperliquid’s API relatively simple is that it only exposes two endpoints: /info and /exchange.

/info

The /info endpoint fetches exchange and user information. Hyperliquid’s documentation states:

“The info endpoint is used to fetch information about the exchange and specific users. The different request bodies result in different corresponding response body schemas.”

A key distinction: information about the exchange (order books, mids, candles, etc.) is public, while user-specific information (balances, positions, open orders) requires you to include a wallet or vault address in the request body.

Requests to /info are always read-only. When you send a payload, the exact request body determines the type of response you’ll get. For example, {"type": "l2Book", "coin": "ETH"} returns the ETH order book, while {"type": "clearinghouseState", "user": "0x..."} returns the account state for a specific wallet or vault.

/exchange

The /exchange endpoint is used for trading. Here you submit signed actions such as placing or cancelling orders, updating leverage, or adjusting isolated margin. Because these actions change state on the chain, they must be signed with an API agent wallet.

Hyperliquid’s docs strongly recommend using one of their existing SDKs to avoid subtle bugs in signature generation; there are many potential ways in which a signature can be malformed. For this series, I manually implement signing, adapting the approach from the async-hyperliquid project. This gives us more control and a deeper understanding of how the API actually works.

Note: All examples in this article use the Hyperliquid Testnet, a sandbox with simulated assets and no real financial risk. Practice API calls and orders here before moving to Mainnet, where real capital is at risk.

Imports

For this project, I will use the following imports:

import time
import datetime
import pandas as pd
import numpy as np
import msgpack
from eth_utils import keccak, to_hex
from eth_account import Account
from eth_account.messages import encode_typed_data
import requests
import json
from utils import int_2_timestamp, timestamp_2_int

Hyperliquid Client: Public Info Functions

First, I will write the public methods for the HyperliquidClient class.

class HyperliquidClient:
    def __init__(self, 
                 private_key: str, 
                 is_mainnet: bool = True, 
                 user_address: str | None = None,
                 vault_address: str | None = None):
        self.private_key = private_key
        self.account = Account.from_key(self.private_key)
        self.vault_address = vault_address.lower() if vault_address else None
        self.user_address = user_address or self.vault_address
        self.is_mainnet = is_mainnet
        if is_mainnet:
            self.base_url = "https://api.hyperliquid.xyz"
        else:
            self.base_url = "https://api.hyperliquid-testnet.xyz"
        self.session = requests.Session()
        self.headers = {"Content-Type": "application/json"}
        self.last_nonce = 0

The user address identifies your wallet, while the vault address is used for any vaults you create. While a single variable might seem sufficient, trading from a vault requires the vault address. In contrast, trades from your main account do not need the user address. This distinction clarifies why both fields are necessary.

Submitting Requests to Hyperliquid

This helper function encapsulates HTTP requests to Hyperliquid. It streamlines REST calls and ensures each response is processed accurately:

    def post(self, 
             payload: dict, 
             endpoint: str = "info"):
        url = f"{self.base_url}/{endpoint}"
        r = self.session.post(url, headers=self.headers, json=payload, timeout=15)
        r.raise_for_status()
        return r.json()

Requesting Data from Hyperliquid

Funding Rate

Hyperliquid, like other perpetual exchanges, employs a funding mechanism to align perpetual and spot prices. On this platform, funding settles hourly, rather than every eight hours, resulting in smaller but smoother payments. This difference helps illustrate how Hyperliquid structures its funding for continuous market alignment.

    def get_funding_rate_historical(self, 
                                    coin: str,
                                    start_time,
                                    end_time=None):
        st = start_time
        if not isinstance(st, int):
            try:
                st = timestamp_2_int(start_time, unit='ms')
            except:
                print(f'Start time {start_time} not in the correct format')
                return None
    
        payload = {
            'type': 'fundingHistory',
            'coin': coin,
            'startTime': st,
        }
    
        if end_time is not None:
            et = end_time
            if not isinstance(et, int):
                try:
                    et = timestamp_2_int(end_time, unit='ms')
                except:
                    print(f'End time {end_time} not in the correct format, it will be ignored')
                    et = None
            if et is not None:
                payload['endTime'] = et     
        return self.post(payload)

The get_funding_rate_historical function retrieves historical funding rates. For instance, this code fetches XRP funding rates from the last 3 hours up to the current time:

now = datetime.datetime.now(datetime.UTC)
prev = now - datetime.timedelta(hours=3)
client.get_funding_rate_historical('XRP', prev)

[{'coin': 'XRP',
  'fundingRate': '0.0000125',
  'premium': '-0.0002416848',
  'time': 1756447200004},
 {'coin': 'XRP',
  'fundingRate': '0.0000125',
  'premium': '-0.0002600937',
  'time': 1756450800036},
 {'coin': 'XRP',
  'fundingRate': '0.0000125',
  'premium': '-0.000279546',
  'time': 1756454400080}]

In this example, the XRP funding rate is slightly positive (0.0000125), which means longs pay shorts, even though the premium component is negative.

The premium field measures how far the perp price trades above or below Hyperliquid’s oracle price. A positive premium means the perp is priced higher than the oracle; a negative premium means it is lower. The funding rate, derived from the premium and other terms, determines payment direction each interval.

An oracle is a best estimate of the “real” underlying (spot) price, typically derived from external price sources. It will not necessarily be the same as the spot price quoted on Hyperliquid.

Spot prices on Hyperliquid are set by supply and demand in the spot orderbook. You don’t want funding and liquidations to be driven by a single venue’s local quirks or a temporary spike.

Not to mention that on any venue that lists a perp but not the corresponding spot market, there is no local spot orderbook at all. In those cases, an external oracle is not just a cleaner reference; it is the only way to define a fair underlying price and compute the premium and funding.

Think of the oracle as Hyperliquid’s best reference for the true market price, used as a reference to compute premiums and funding.

Note that Hyperliquid does not keep any part of the funding; payments flow directly between traders on opposite sides of the contract.

The get_funding_rate_historical function returns integer timestamps (the time field). To convert those integers into readable timestamps you can use the helper function int_2_timestamp, defined in the Util functions section.

Additionally, in this example, we invoked the get_funding_rate_historical to request funding rates starting 3 hours ago. For this, we need the function timestamp_2_int, also defined in the Util functions section, that converts a datetime into an integer.

Candlestick Data

    def get_klines(self, 
                   coin: str, 
                   interval: str, 
                   start_time_ms: int, 
                   end_time_ms: int | None = None):
        payload = {
            "type": "candleSnapshot",
            "req": {
                "coin": coin,
                "interval": interval,
                "startTime": int(start_time_ms),
            },
        }
        if end_time_ms is not None:
            payload["req"]["endTime"] = int(end_time_ms)
        return self.post(payload)

The get_klines function retrieves historical candlestick (kline) data for a given symbol. Each candlestick includes OHLC (Open, High, Low, Close) prices, volume, and supporting metadata for the specified interval. Klines are indexed by open time, making them suitable for technical analysis and backtesting. On Testnet, small intervals may return empty responses due to low liquidity.

now = timestamp_2_int(datetime.datetime.now(datetime.UTC))
from_time = now - 3600 * 1000
client.get_klines("ETH", "1h", from_time, now)

[{'t': 1756461600000,
  'T': 1756465199999,
  's': 'ETH',
  'i': '1h',
  'o': '4335.2',
  'c': '4344.1',
  'h': '4359.9',
  'l': '4331.0',
  'v': '40037.9033',
  'n': 17804},
 {'t': 1756465200000,
  'T': 1756468799999,
  's': 'ETH',
  'i': '1h',
  'o': '4344.2',
  'c': '4340.2',
  'h': '4356.5',
  'l': '4331.2',
  'v': '47793.1251',
  'n': 12231}]

Order book

    def get_order_book(self, 
                       coin: str,
                       n_sig_figs: int = 5,
                       mantissa: int | None = None):
        """
        Fetch Level-2* (depth) order book snapshot for a perp coin (e.g., 'BTC', 'ETH').
    
        Note: "L2" here means Level-2 market data (depth by price level), 
        not Layer-2 blockchain. Hyperliquid's order book itself runs on their Layer-1 chain.

        Notes:
        - API accepts precision controls via nSigFigs/mantissa.
        - Returns the raw API JSON
        """
        payload = {
            "type": "l2Book",
            "coin": coin,
            "nSigFigs": int(n_sig_figs),
            "mantissa": mantissa,   # leave as None unless you want fixed mantissa
        }
        data = self.post(payload)
        return data

The get_order_book function gets the order book. Here is an example of usage and response:

client.get_order_book('BTC')

{'coin': 'BTC',
 'time': 1756487858150,
 'levels': [[{'px': '108240.0', 'sz': '0.26835', 'n': 1},
   {'px': '108230.0', 'sz': '1.01548', 'n': 2},
   {'px': '108220.0', 'sz': '0.00047', 'n': 1},
   {'px': '108210.0', 'sz': '1.02533', 'n': 2},
   {'px': '108200.0', 'sz': '1.05268', 'n': 5},
   {'px': '108190.0', 'sz': '0.001', 'n': 1},
   {'px': '108150.0', 'sz': '0.00012', 'n': 1},
   {'px': '108130.0', 'sz': '0.49184', 'n': 2},
   {'px': '108110.0', 'sz': '0.55222', 'n': 2},
   {'px': '108020.0', 'sz': '0.00704', 'n': 1},
   {'px': '107960.0', 'sz': '1.03574', 'n': 2},
   {'px': '107700.0', 'sz': '1.01475', 'n': 2},
   {'px': '107300.0', 'sz': '0.0001', 'n': 1},
   {'px': '107230.0', 'sz': '0.5472', 'n': 1},
   {'px': '107180.0', 'sz': '0.55876', 'n': 1},
   {'px': '107100.0', 'sz': '0.00517', 'n': 2},
   {'px': '106900.0', 'sz': '2.65871', 'n': 1},
   {'px': '106650.0', 'sz': '2.57184', 'n': 1},
   {'px': '106180.0', 'sz': '0.00757', 'n': 1},
   {'px': '106120.0', 'sz': '2.35709', 'n': 1}],
  [{'px': '108270.0', 'sz': '0.35742', 'n': 2},
   {'px': '108280.0', 'sz': '0.33208', 'n': 2},
   {'px': '108290.0', 'sz': '0.64762', 'n': 2},
   {'px': '108310.0', 'sz': '0.62296', 'n': 2},
   {'px': '108340.0', 'sz': '0.66405', 'n': 2},
   {'px': '108410.0', 'sz': '0.32681', 'n': 1},
   {'px': '108420.0', 'sz': '0.29849', 'n': 1},
   {'px': '108510.0', 'sz': '0.0004', 'n': 1},
   {'px': '108560.0', 'sz': '0.47301', 'n': 1},
   {'px': '108600.0', 'sz': '0.48307', 'n': 1},
   {'px': '108930.0', 'sz': '1.02799', 'n': 2},
   {'px': '109220.0', 'sz': '0.0008', 'n': 2},
   {'px': '109650.0', 'sz': '0.47413', 'n': 1},
   {'px': '109680.0', 'sz': '0.50332', 'n': 1},
   {'px': '109800.0', 'sz': '0.0001', 'n': 1},
   {'px': '109870.0', 'sz': '0.001', 'n': 1},
   {'px': '109890.0', 'sz': '0.001', 'n': 1},
   {'px': '110200.0', 'sz': '2.28857', 'n': 1},
   {'px': '110270.0', 'sz': '0.00011', 'n': 1},
   {'px': '110350.0', 'sz': '2.74469', 'n': 1}]]}
                 Order Book (simplified) — BTC (Testnet)

           Asks (Sellers)                      Bids (Buyers)
    ---------------------------------------------------------------
      Price         Quantity               Price         Quantity
    108270.0        0.35742   ← lowest    108240.0        0.26835   ← highest
    108280.0        0.33208               108230.0        1.01548
    108290.0        0.64762               108220.0        0.00047
    108310.0        0.62296               108210.0        1.02533
    108340.0        0.66405               108200.0        1.05268
    ---------------------------------------------------------------

    Spread = 108270.0108240.0 = 30.0
    Mid    = (108270.0 + 108240.0) / 2 = 108255.0

The order book shows two sides of the market: bids and asks.

Bids are buy offers. Each bid specifies a price level and the corresponding quantity buyers are willing to purchase at that price. For example, in our BTC testnet snapshot, a bid of 108230.0 with a quantity of 1.01548 indicates buyers want slightly more than one contract at 108,230.

Asks are sell offers. Each ask details a price level, and the quantity sellers are offering. For instance, an ask of 108280.0 with a quantity of 0.33208 means sellers offer about a third of a contract at 108,280.

The bid-ask spread is the difference between the highest bid (the best buyer price) and the lowest ask (the best price a seller is willing to accept). In this example, the highest bid is108240.0, and the lowest ask is 108270.0, resulting in a spread of 30.0. The spread is a direct measure of market liquidity: tighter spreads usually indicate deeper, more liquid markets, while wider spreads suggest lower liquidity.

One useful method to assess whether the spread is “tight” or “wide” is to measure it in terms of ticks. The tick size is the minimum price movement permitted for a symbol. While Hyperliquid does not enforce a fixed tick size, the effective tick can be inferred from the following rules (which only apply to futures; spot has other rules):

  • The maximum number of decimal places (MAX_DECIMALS) a price can have is 6. The general rule for the decimal places is: MAX_DECIMALS − szDecimals.
  • Prices have a maximum of 5 significant figures.
  • Integer prices are always allowed.

For BTC the maximum number of decimal places is 6 − 5 = 1. But, at the time of writing this paragraph, the BTC price is more than 100K and consequently has 6 significant figures. This, together with the fact that integer prices are always accepted, implies that the effective BTC tick size is, at the moment, 1.

In this snapshot, the spread is 30.0, which means the spread is 30.0 / 1 = 30 ticks. While this is a large spread, note that this data comes from testnet, where trading is thin, and order books might not reflect actual market liquidity. On mainnet, spreads are typically much narrower and often approach 1 tick.

In formula form, the spread in ticks can be written as: (best ask – best bid) / tick size. For our BTC testnet example: (108270.0 – 108240.0) / 1 = 30 ticks.

Symbol info

    def get_symbol_info(self, MAX_DECIMALS: int = 6):
        meta = self.post({"type": "meta"})
        symbol_info = {}
        for i, item in enumerate(meta["universe"]):
            szd = int(item["szDecimals"])
            px_dec_cap = max(0, MAX_DECIMALS - szd)  # price decimal cap
    
            symbol_info[item["name"]] = {
                "assetIndex": i,
                "szDecimals": szd,
                "stepSize": 10 ** (-szd),          # size grid
                "priceDecimals": px_dec_cap,       # max decimals for price
                "maxSigFigs": 5,                   # HL rule (integers always allowed)
                "maxLeverage": float(item["maxLeverage"]),
                "marginTableId": item["marginTableId"],
            }
        return symbol_info

get_symbol_info fetches metadata for all tradable symbols, including key execution constraints like minimum order size, price precision (tick), and notional thresholds. Use this to validate and normalize orders before submitting them, reducing the chance of exchange rejections.

The assetIndex field will be used for trading, as Hyperliquid’s execution API expects a coin index rather than a coin name.

client.get_symbol_info()

{'SOL': {'assetIndex': 0,
  'szDecimals': 2,
  'stepSize': 0.01,
  'priceDecimals': 4,
  'maxSigFigs': 5,
  'maxLeverage': 10.0,
  'marginTableId': 10},
 'APT': {'assetIndex': 1,
  'szDecimals': 2,
  'stepSize': 0.01,
  'priceDecimals': 4,
  'maxSigFigs': 5,
  'maxLeverage': 3.0,
  'marginTableId': 3},
 'ATOM': {'assetIndex': 2,
  'szDecimals': 2,
  'stepSize': 0.01,
  'priceDecimals': 4,
  'maxSigFigs': 5,
  'maxLeverage': 10.0,
  'marginTableId': 55},
 'BTC': {'assetIndex': 3,
  'szDecimals': 5,
  'stepSize': 0.00001,
  'priceDecimals': 1,
  'maxSigFigs': 5,
  'maxLeverage': 40.0,
  'marginTableId': 54},
  etc.
}

Util Functions

def int_2_timestamp(ts):
    digits = (np.log10(ts) + 1).astype(int)
    dt = None
    if digits == 10:
        dt = pd.to_datetime(ts, unit="s", utc=True)
    elif digits == 13:
        dt = pd.to_datetime(ts, unit="ms", utc=True)
    elif digits == 16:
        dt = pd.to_datetime(ts, unit="us", utc=True)
    return dt

def timestamp_2_int(dt, unit="ms"):
    if unit == "ms":
        return int(dt.timestamp() * 1_000)
    elif unit == "us":
        return int(dt.timestamp() * 1_000_000)
    elif unit == "s":
        return int(dt.timestamp())
    else:
        raise ValueError(f"Unsupported unit: {unit}")

The int_2_timestamp function converts an integer to a timestamp. The timestamp_2_int function performs the reverse operation, converting a datetime to an integer based on the specified unit. The datetime should be timezone-aware or in UTC.

The units should be "s" (seconds), "ms" (milliseconds), or "us" (microseconds).

Conclusion

In Part I, I covered the various Hyperliquid APIs, the distinction between Mainnet and Testnet, and how to obtain the API keys.

In this article, I implemented Hyperliquid's /info public functions, which do not require user or vault addresses because they operate without authentication.

In Part III, I will cover the private /info functions as well as /exchange functions which need to be signed. This includes account status checks, setting leverage and margin, placing and canceling orders, and reviewing trades and positions.

Want deeper insights into risk and trading strategies? Subscribe to Trading Shepherd today and stay ahead of market volatility!"