Back to Blog

Building a Hyperliquid Client in Python – Part III

Part III of the Hyperliquid client series covers private API endpoints, secure EIP-712 signing, and comprehensive order management. It offers practical guidance for account functions, error handling, and safe Testnet experimentation before transitioning to Mainnet.

Posted by

Hyperliquid client

Introduction

Part I explained how to start building a Hyperliquid client.

Part II covered the /info endpoint’s public methods, which need no user or vault address.

Having established these foundations, let's now move to Part III, which introduces both /info private methods and /exchange methods.

For clarity, the HyperliquidClient example here minimizes error handling. In reality, strong failure handling is crucial. APIs can send unexpected payloads, rate-limit notices, or face connection issues. In live trading, ignoring these can mean missed fills, duplicate orders, or unwanted positions. A production client needs features like retries with backoff, request timeouts, and detailed logging to spot and fix problems.

One disclaimer: I am a researcher, not a professional developer. While the code has worked well during my own testing and examples, it has not gone through comprehensive production-level vetting. Consider it a learning tool or starting point rather than a production-ready solution.

Trading futures is high risk. You can lose more than your initial margin. This material is for educational purposes only.

Note: All examples use the Hyperliquid Testnet, a sandbox with simulated assets and no real risk. Testnet is the safest place to try API calls and signing before moving to Mainnet, where real capital is at stake.

Private /info methods

I will start by writing the private /info methods.

Account info

    def get_account_info(self):
        payload = {
            'type': 'clearinghouseState',
            'user': self.user_address,
        }
        return self.post(payload)

The get_account_info function gets the current account information, including positions.

Open orders

    def get_open_orders(self, 
                        coin: str | None = None):
        """
        Returns all open orders for the user, optionally filtered by coin.
        """
        payload = {"type": "openOrders", "user": self.user_address}
        resp = self.post(payload)

        # normalize shape
        orders = resp.get("openOrders", resp) if isinstance(resp, dict) else resp
        if not isinstance(orders, list):
            return []

        if coin is None:
            return orders

        sym = coin.upper()
        return [
            o for o in orders
            if (o.get("order", {}).get("coin") or o.get("coin", "")).upper() == sym
        ]

Recent trades

    def get_my_trades(
        self,
        start_time=None,   
        end_time=None, # defaults to now if start_time is not set
        aggregate_by_time: bool = True, 
        coin: str | None = None,
    ):
        """
        Fetch recent fills (executed trades) for this account.
        Uses 'userFills' (latest up to 2000) or 'userFillsByTime'
        (time window; max 2000 per call).
        Only the ~10,000 most recent fills are available server-side.
        """

        st = None
        if start_time is not None:
            st = start_time
            if not isinstance(st, int):
                try:
                    st = timestamp_2_int(start_time, unit='ms')
                except Exception:
                    print(f'Start time {start_time} not in the
                            correct format, it will be ignored')
                    st = None
        
        if st is None:
            payload = {
                "type": "userFills",
                "user": self.user_address,
                "aggregateByTime": bool(aggregate_by_time),
            }
            if end_time is not None:
                print("start_time is None, end_time will be ignored")
            fills = self.post(payload)
        else:
            et = int(time.time() * 1000)
            if end_time is not None:
                et = end_time
                if not isinstance(et, int):
                    try:
                        et = timestamp_2_int(end_time, unit='ms')
                    except Exception:
                        print(f'End time {end_time} not in the 
                                correct format, it will be ignored')
                        et = int(time.time() * 1000)     
    
            payload = {
                "type": "userFillsByTime",
                "user": self.user_address,
                "startTime": int(st),
                "endTime": int(et),
                "aggregateByTime": bool(aggregate_by_time),
            }
        
            fills = self.post(payload)
        # end if
        
        # flatten if server returns {"fill": {...}}
        fills = [f.get("fill", f) for f in (fills or [])]
        
        if coin is not None:
            sym = coin.upper()
            c_fills = [f for f in fills if f.get("coin", "").upper() == sym]
            return c_fills
        
        return fills

Signing

Hyperliquid’s docs strongly recommend using one of their existing SDKs: “It is recommended to use an existing SDK instead of manually generating signatures. There are many potential ways in which signatures can be wrong.” (see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/signing)

For this series, however, I’ve chosen to implement the signing manually, adapting the approach from the async-hyperliquid project. This gives us more control and a deeper understanding of how the API works under the hood.

To submit an action to Hyperliquid you must prove intent with an EIP-712 signature. Hyperliquid wraps a connectionId (a Keccak-256 hash of your action payload + an 8-byte nonce + an optional vault flag/address) inside a simple Agent typed-data message and asks you to sign it. This binds the signature to exactly what you’re doing (the msgpacked action), when you’re doing it (the nonce), and who you’re doing it as (wallet or vault), while staying replay-safe and frontend-agnostic.

EIP-712 is an Ethereum standard that defines how to encode, hash, and sign typed structured data with a domain separator, producing a deterministic digest that wallets can show and systems can verify. It improves security by preventing cross-domain replays and reduces phishing by making the signed fields explicit.

Keccak-256 is a cryptographic hash function that takes any bytes as input and deterministically produces a fixed-length 32-byte (256-bit) digest. It is used across Ethereum for things like addresses, logs, and signed message digests. In our Beyond the Buzzwords: Building a Real Blockchain from First Principles blog I give an example of how SHA-256 works, but, of course, Keccak-256 and SHA-256 are different hash functions.

    def hash_action(self, 
                    action: dict, 
                    nonce: int) -> bytes: 
        # Hyperliquid defines the connectionId as (msgpack + nonce + vault flag).                    
        data = msgpack.packb(action)
        data += nonce.to_bytes(8, "big")
        vault = self.vault_address
        if vault is None:
            data += b"\x00"
        else:
            data += b"\x01"
            data += bytes.fromhex(vault.removeprefix("0x"))
        return keccak(data)

msgpack (MessagePack) is a compact, binary serialization format (think “binary JSON”). It encodes common data types (ints, floats, bools, strings, bytes, arrays/lists, maps/dicts) into a small, fast-to-parse byte stream. It’s widely used when you need JSON-like structures but with less overhead and exact binary fidelity.

    def sign_action(self, 
                    action: dict, 
                    nonce: int) -> dict:

        h = self.hash_action(action, nonce)

        # wrap that connectionId in the EIP-712 Agent schema and sign it        
        # 1) EIP-712 typed data (Agent)
        typed = {
            "domain": {
                "name": "Exchange",
                "version": "1",
                "chainId": 1337,
                "verifyingContract": "0x0000000000000000000000000000000000000000",
            },
            "types": {
                "Agent": [
                    {"name": "source", "type": "string"},
                    {"name": "connectionId", "type": "bytes32"},
                ],
                "EIP712Domain": [
                    {"name": "name", "type": "string"},
                    {"name": "version", "type": "string"},
                    {"name": "chainId", "type": "uint256"},
                    {"name": "verifyingContract", "type": "address"},
                ],
            },
            "primaryType": "Agent",
            "message": {
                "source": "a" if self.is_mainnet else "b",
                "connectionId": h,  # bytes32
            },
        }
    
        # 2) Build SignableMessage
        signable = encode_typed_data(full_message=typed)
    
        # 3) Sign -> return JSON-safe fields
        signed = self.account.sign_message(signable)
        return {"r": to_hex(signed.r), "s": to_hex(signed.s), "v": int(signed.v)}
    def get_nonce(self) -> int:
        """
        Hyperliquid requires nonces to be:
          - unique (no repeats),
          - strictly increasing (every new nonce > previous),
          - in milliseconds, close to current time,
          - within [now - 2 days, now + 1 day].
    
        This function generates such nonces locally.
        """
        n = int(time.time() * 1000)  # 1) Current Unix time in ms
        if n <= self.last_nonce:     # 2) If we already used this ms 
            n = self.last_nonce + 1  #    (or got a smaller one) bump it up by 1
        self.last_nonce = n          # 3) Store it as the new "last used" nonce
        return n

/exchange methods

Cancel order

    def cancel_order(self,
                     coin_idx: int,
                     oid: int):
        payload = {"type": "cancel", "cancels": [{"a": coin_idx, "o": int(oid)}]}
        resp = self.execute_order(payload)
        return resp

Execute order

    def execute_order(self,
                      action: dict,
                      expires_after_ms: int | None = None):
        if not action:
            print("action was empty")
            return None
    
        nonce = self.get_nonce()
        sig = self.sign_action(action, nonce=nonce)
    
        payload = {"action": action, "nonce": nonce, "signature": sig}
        if expires_after_ms is not None:
            payload["expiresAfter"] = int(expires_after_ms)
        if self.vault_address:
            payload["vaultAddress"] = self.vault_address.lower()
    
        r = self.post(payload, endpoint="exchange")
        return r

Set leverage and margin

    def set_leverage(self, 
                     coin_idx: int, 
                     leverage: int, 
                     is_cross: bool = True):
        """
        Set cross or isolated leverage for a coin.
        - leverage: integer between 1 and the coin's maxLeverage
        - is_cross: True = cross, False = isolated
        """
        action = {
            "type": "updateLeverage",
            "asset": coin_idx,
            "isCross": bool(is_cross),
            "leverage": int(leverage),
        }
        return self.execute_order(action)

Order building util function

    def build_limit_order(self, 
                          coin_idx: int,
                          is_buy: bool,
                          sz: float | str,
                          px: float | str,
                          tif: str = "Gtc",
                          reduce_only: bool = False,
                          cloid: str | None = None):

        pxs = str(px)
        szs = str(sz)
        order = {
            "a": coin_idx,
            "b": bool(is_buy),
            "p": pxs.rstrip('0').rstrip('.') if '.' in pxs else pxs,
            "s": szs.rstrip('0').rstrip('.') if '.' in szs else szs,
            "r": bool(reduce_only),
            "t": {"limit": {"tif": tif}},
        }
        if cloid:
            order["c"] = cloid
        return {"type": "order", "orders": [order], "grouping": "na"}

Example usage

In this section I will show how to use this code. I will be using my vault on the Testnet.

Client initialization

client = HyperliquidClient(
    test_private_key, 
    is_mainnet=False, 
    user_address=None,
    vault_address=test_vault_key,
)

Account snapshot

client.get_account_info()

{'marginSummary': {'accountValue': '450.0',
  'totalNtlPos': '0.0',
  'totalRawUsd': '450.0',
  'totalMarginUsed': '0.0'},
 'crossMarginSummary': {'accountValue': '450.0',
  'totalNtlPos': '0.0',
  'totalRawUsd': '450.0',
  'totalMarginUsed': '0.0'},
 'crossMaintenanceMarginUsed': '0.0',
 'withdrawable': '450.0',
 'assetPositions': [],
 'time': 1756626195756}            

At the moment I have the initial amount I deposited in the vault and no positions.

Setting margin mode and leverage

Both margin mode and leverage are set in one go.

symbol_info = client.get_symbol_info()
coin_idx = symbol_info['HYPE']['assetIndex']

client.set_leverage(coin_idx, 5, False)

{'status': 'ok', 'response': {'type': 'default'}}                    

With this action I have set HYPE's leverage to 5 and the margin mode to isolated.

Executing a limit order

symbol_info = client.get_symbol_info()
coin = "HYPE"
px   = "35.0" 
sz   = "1" 
coin_idx = symbol_info[coin]["assetIndex"]

limit_order = client.build_limit_order(
    coin_idx, is_buy=True, sz=sz, px=px, tif="Gtc"
)

client.execute_order(limit_order)

{'status': 'ok',
 'response':
    {'type': 'order',
             'data': {'statuses': [{'resting': {'oid': 38317782182}}]}}}      

Checking for open orders

client.get_open_orders(coin='HYPE')

[{'coin': 'HYPE',
  'side': 'B',
  'limitPx': '35.0',
  'sz': '1.0',
  'oid': 38317782182,
  'timestamp': 1756639388523,
  'origSz': '1.0'}]   

Canceling open orders

client.cancel_order(coin_idx, 38317782182)

{'status': 'ok',
 'response': {'type': 'cancel', 'data': {'statuses': ['success']}}} 
client.get_open_orders(coin='HYPE')

[]

Executing a market order

Hyperliquid’s exchange endpoint doesn’t expose a standalone “market” order type.

But it is possible to build a market-like order, following these steps:

  • Get the order book.
  • From the order book, get a price that will very likely have your order fully executed.
  • Make sure the previous price is valid (otherwise the order will be rejected).
  • Use the build_limit_order method to build the order using tif="Ioc" (immediate or cancel).

Checking positions and trades

Let's now consider another limit order example, this time one that will actually trade:

symbol_info = client.get_symbol_info()
coin = "BTC"
px   = "114011" 
sz   = "0.0001" 
coin_idx = symbol_info[coin]["assetIndex"]
limit_order = client.build_limit_order(
    coin_idx, is_buy=True, sz=sz, px=px, tif="Ioc"
)

This order is a “market-like order”. I chose a price so the order would be executed with high probability, and I used tif="Ioc". Of course, as with any other limit order, there is no guarantee of execution.

Order execution:

client.execute_order(limit_order)

{'status': 'ok',
 'response': {'type': 'order',
  'data': {'statuses': [{'filled': {'totalSz': '0.0001',
      'avgPx': '113997.0',
      'oid': 38980114448}}]}}}      

The order was executed for a total size of 0.0001 BTC (so I was able to trade my entire desired quantity) at an average price of 113,997, which is below the price I was willing to pay.

I can now check my BTC positions:

client.get_positions(coin='BTC')

{'type': 'oneWay',
 'position': {'coin': 'BTC',
  'szi': '0.0001',
  'leverage': {'type': 'isolated', 'value': 5, 'rawUsd': '-9.123671'},
  'entryPx': '113997.0',
  'positionValue': '11.3951',
  'unrealizedPnl': '-0.0046',
  'returnOnEquity': '-0.002017597',
  'liquidationPx': '92391.6050632911',
  'marginUsed': '2.271429',
  'maxLeverage': 40,
  'cumFunding': {'allTime': '0.000142',
   'sinceOpen': '0.000142',
   'sinceChange': '0.000142'}}}     

Checking the trades:

client.get_my_trades(
    start_time=None,   
    end_time=None, 
    aggregate_by_time=True,  
    coin=None,
)

[{'coin': 'BTC',
  'px': '113997.0',
  'sz': '0.0001',
  'side': 'B',
  'time': 1757586657221,
  'startPosition': '0.0',
  'dir': 'Open Long',
  'closedPnl': '0.0',
  'hash': '0x407b0e9ee3dc736141f40419a65a5f01020026847edf9233e443b9f1a2d04d4b',
  'oid': 38980114448,
  'crossed': True,
  'fee': '0.005129',
  'tid': 833390239121581,
  'feeToken': 'USDC',
  'twapId': None}]                     

Conclusion

This series has walked through the essential steps to building a Hyperliquid client. In Part I, we explored the different Hyperliquid APIs, clarified the distinction between Mainnet and Testnet, and demonstrated how to obtain API keys. Part II focused on implementing Hyperliquid's /info public functions, which do not require user or vault addresses. The final section covered private /info and/exchange functions that require signing, such as checking account status, adjusting leverage and margin, managing orders, and monitoring trades and positions.

While this guide provides a foundation for understanding and interacting with Hyperliquid’s API, building a robust production client will require extra care around error handling, security, and rigorous testing. I encourage you to experiment on Testnet, explore further features, and consider contributing improvements back to the community.

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