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
Related reading
Building a Hyperliquid Client in Python – Part I
A practical, developer-focused introduction to building a Python client for Hyperliquid perpetual futures from scratch. Covers core concepts like APIs, orderbooks, and perps while setting up the foundation for querying data and interacting with the exchange.

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_intHyperliquid 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 = 0The 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.0 − 108240.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!"