Skip to main content
Copy trading on Hyperliquid requires more than just watching orders—you need proper state management, event sequencing, and dynamic order sizing. This guide shows you how to build a copy trading bot that mirrors spot trades in real-time.

Understanding Hyperliquid’s WebSocket architecture

Hyperliquid provides two essential WebSocket channels for monitoring trading activity. These channels serve different purposes: orderUpdates delivers WsOrder[] events containing order status changes—when orders are placed, modified, or canceled. This is your primary signal for mirroring trading decisions. userEvents provides WsUserEvent data including fills, funding payments, and liquidations. For this tutorial, the most important part is that the channel provides TWAP order updates not available in orderUpdates. Together, these channels give you a complete picture of a trader’s activity:
order_subscription = {
    "method": "subscribe",
    "subscription": {"type": "orderUpdates", "user": LEADER_ADDRESS}
}

events_subscription = {
    "method": "subscribe",
    "subscription": {"type": "userEvents", "user": LEADER_ADDRESS}
}

await websocket.send(json.dumps(order_subscription))
await websocket.send(json.dumps(events_subscription))

How spot markets work on Hyperliquid

Before diving into implementation, you need to understand how Hyperliquid distinguishes spot from perpetual assets. The coin field format tells you the market type: Perpetual contracts use simple asset names returned from the meta endpoint:
  • BTC — Bitcoin perpetual
  • ETH — Ethereum perpetual
  • SOL — Solana perpetual
Spot markets use two formats:
  • @{index} — Numeric spot pair index (e.g., @0 for BTC/USDC, @107 for HYPE/USDC)
  • {TOKEN}/USDC — Human-readable pair names (e.g., PURR/USDC)
Why the @index format? It’s Hyperliquid’s internal representation—more efficient for the order book engine. When you receive WebSocket events, the coin field typically contains the @index format for spot trades. This matters for copy trading because:
  1. You receive @123 in WebSocket events
  2. You need to query metadata using that index
  3. You place orders using the same @index format
The index maps to token metadata through the spotMeta endpoint, which provides crucial information like price precision and size decimals.

Detecting spot vs perpetual orders

Your bot monitors a trader who trades both spot and perps. Spot orders are cash trades (you own the asset), perps involve leverage and funding payments. For simplicity, you might want to mirror only spot trades initially. The coin field in WebSocket events tells you the market type:
def detect_market_type(coin_field):
    if coin_field.startswith("@"):
        return "SPOT"  # "@123" = spot index format
    elif "/" in coin_field:
        return "SPOT"  # "PURR/USDC" = spot pair name
    else:
        return "PERP"  # "BTC", "ETH" = perpetual contracts
This logic follows Hyperliquid’s naming convention: if the coin starts with @ or contains /, it’s a spot market. Otherwise, it’s a perpetual contract. Filter to spot-only mirroring:
def is_spot_order(coin_field):
    return detect_market_type(coin_field) == "SPOT"

# In your event handler
if not is_spot_order(coin_field):
    continue  # Skip perp orders

Dynamic order sizing for spot assets

A leader might trade $10,000 of BTC, but your bot has a $1,000 budget. You need proportional sizing based on your capital allocation. But it’s not just about scaling down—Hyperliquid enforces strict precision rules. According to the official documentation: Size precision — Round to the asset’s szDecimals (found in meta response):
  • szDecimals = 3: 1.001 valid, 1.0001 invalid
  • szDecimals = 1: 0.1 valid, 0.01 invalid
Price precision — Max 5 significant figures, no more than MAX_DECIMALS - szDecimals decimal places (spot: MAX_DECIMALS = 8, perps: MAX_DECIMALS = 6):
  • szDecimals = 0: 0.0001234 valid (8 decimals)
  • szDecimals = 2: 0.0001234 invalid (exceeds 8-2=6 decimals)
  • 1234.5 valid, 1234.56 invalid (too many sig figs)
Query metadata and calculate valid sizes:
async def get_spot_asset_info(info: Info, coin_field: str) -> Optional[dict]:
    """Get real-time price and size decimals for proper order sizing"""
    spot_data = info.spot_meta_and_asset_ctxs()
    spot_meta = spot_data[0]
    asset_ctxs = spot_data[1]

    index = int(coin_field[1:])  # "@123" -> 123
    ctx = asset_ctxs[index]
    price = float(ctx.get("midPx", ctx.get("markPx", 0)))

    pair_info = next(p for p in spot_meta["universe"] if p["index"] == index)
    token_info = spot_meta["tokens"][pair_info["tokens"][0]]

    return {
        "price": price,
        "szDecimals": token_info.get("szDecimals", 6),
        "coin": coin_field
    }

# Calculate and round order size
asset_info = await get_spot_asset_info(info, "@0")
follower_size = round(
    FIXED_ORDER_VALUE_USDC / asset_info["price"],
    asset_info["szDecimals"]
)
Example: $15 / $65,000 BTC with szDecimals = 50.00023076923... rounds to 0.00023.

Understanding order lifecycle and state transitions

Orders on Hyperliquid go through a lifecycle with specific state transitions. When you subscribe to orderUpdates, you receive events for each state change.

Order state events explained

open status — A new order has been placed on the book by the leader. canceled status — Order removed from the book, either by:
  • Trader manually canceling
  • Part of a modification (old order canceled, new order created with different ID)
  • System cancellation (insufficient balance, delisting, etc.)
filled status — Order fully executed. You’ll also see detailed fill information in the userEvents channel.

How order modifications work

When a trader modifies an order on Hyperliquid (changes price or size), the exchange handles this atomically as:
  1. Cancel the existing order (you receive "canceled" event)
  2. Create a new order with the new parameters (you receive "open" event with new order ID)
This means you don’t need special modification logic—simply handle cancellations and new orders:
async def handle_leader_order_events(data: dict, exchange: Exchange, info: Info):
    """Process order lifecycle events from leader"""

    for order_update in data.get("data", []):
        order = order_update.get("order", {})
        status = order_update.get("status")
        leader_order_id = order.get("oid")
        coin_field = order.get("coin", "")

        # Skip non-spot orders
        if not is_spot_order(coin_field):
            continue

        if status == "open":
            # New order placed - mirror it
            follower_order_id = await place_follower_order(exchange, info, order)
            if follower_order_id:
                # Store mapping for future cancellations
                order_mappings[leader_order_id] = follower_order_id

        elif status == "canceled" and leader_order_id in order_mappings:
            # Leader canceled an order - cancel our follower order
            follower_order_id = order_mappings[leader_order_id]
            await cancel_follower_order(exchange, follower_order_id, coin_field)
            # Remove from mapping - this order is no longer active
            del order_mappings[leader_order_id]
The order_mappings dictionary tracks active orders by mapping each leader order ID to your corresponding follower order ID. This allows you to cancel the correct follower order when the leader cancels

Placing orders with the Hyperliquid SDK

The hyperliquid-python-sdk provides the exchange.order() method for placing trades. Let’s break down each parameter and why it matters:
result = exchange.order(
    name=coin_field,        # "@0" for BTC/USDC - must match WebSocket coin format
    is_buy=True,            # True for buy, False for sell
    sz=order_size,          # Calculated with proper precision from metadata
    limit_px=price,         # Limit price - protects against slippage
    order_type=HLOrderType({"limit": {"tif": "Gtc"}}),  # Good-til-canceled
    reduce_only=False       # False = can open new positions
)
The response structure tells you whether your order was accepted:
if result.get("status") == "ok":
    statuses = result["response"]["data"]["statuses"]
    status_info = statuses[0]

    if "resting" in status_info:
        # Order placed on the book, waiting to fill
        follower_order_id = status_info["resting"]["oid"]
        return follower_order_id
    elif "filled" in status_info:
        # Order executed immediately (market was liquid enough)
        follower_order_id = status_info["filled"]["oid"]
        return follower_order_id
Why check both resting and filled? If you place a limit buy at $100 and the best ask is $99, your order fills immediately. You still need the order ID for tracking fills and state management.

Testing with same wallet (development only)

This section describes a development shortcut for testing. In production, you’ll use separate leader and follower wallets, and this infinite loop problem won’t exist.
When building your bot, you probably don’t want to manage two separate wallets and fund both with testnet USDC. For testing purposes, you can use the same wallet as both leader and follower—but this creates a challenge. The infinite loop problem:
1

Leader places order

The leader wallet (your test wallet) initiates a new order
2

Bot mirrors it as follower

Your bot detects the order and places a mirror order using the same wallet
3

Bot sees its own follower order

WebSocket receives the follower order as a “new leader order” (because you’re watching the same wallet)
4

Bot tries to mirror again

Bot attempts to mirror the follower order, creating another order
5

Infinite loop

Process repeats indefinitely, placing unlimited orders
The solution: Track which orders are follower-placed and skip them:
order_mappings: Dict[int, int] = {}  # leader_order_id -> follower_order_id

# In event handler
if leader_order_id in order_mappings.values():
    # This order ID is a follower order we placed - skip it
    print(f"Skipping follower order {leader_order_id}")
    continue
When you mirror a leader order, you store the mapping {leader_order_id: follower_order_id}. Later, when that follower order appears in the WebSocket feed (because you’re watching your own wallet), you check: “Is this order ID in my follower list?” If yes, ignore it. Important: In production with separate wallets, leader and follower order IDs never overlap—you don’t need this check. This is purely for development convenience.

Sequential processing (simplified for testing)

This section describes a simplified architecture for testing. In production with high-frequency trading, you’d process events in parallel with proper concurrency controls.
When WebSocket messages arrive, you have two architectural choices: Concurrent processing (production):
  • Process multiple order events simultaneously
  • Requires locks/semaphores to prevent race conditions
  • Optimal for high-frequency traders placing hundreds of orders per minute
  • More complex to implement and debug
Sequential processing (testing/development):
  • Process one order event at a time
  • No race conditions by design
  • Simple to implement and reason about
  • Sufficient for mirroring human traders (10-100 orders per hour)
For testing and learning, sequential processing is simpler:
message_queue = asyncio.Queue()

async def message_receiver():
    """Receive messages and queue them"""
    async for message in websocket:
        await message_queue.put(message)

async def message_processor():
    """Process messages one at a time"""
    while running:
        message = await asyncio.wait_for(message_queue.get(), timeout=1.0)
        data = json.loads(message)
        await handle_leader_order_events(data, exchange, info)
        message_queue.task_done()

# Run both tasks concurrently
await asyncio.gather(message_receiver(), message_processor())
The receiver task puts messages in the queue as fast as they arrive. The processor task pulls one message, processes it completely (place order, update mapping, etc.), marks it done, then moves to the next. When to upgrade to parallel: When you’re mirroring algorithmic traders or high-frequency strategies that place 10+ orders per second. At that scale, you need concurrent processing with proper state locking.

Key takeaways

Hyperliquid uses naming conventions: @index or TOKEN/USDC for spot, simple names like BTC for perps.
Modifications appear as canceled old order + new open order, with a different order ID for each.
Since Hyperliquid sends separate cancel and create events for modifications, your bot only needs to handle “open” and “canceled” statuses.
Track leader-to-follower order ID mappings to handle cancellations.
Query szDecimals from spot metadata to calculate properly rounded order sizes for each asset.
Same-wallet testing and sequential processing simplify development but require changes for production.
Complete implementation: See the hyperliquid bot examples repository for the full working code.

See also

I