Skip to main content
You want to buy $50,000 of a low-liquidity altcoin. Place one market order and watch the price slip 5–10% as you consume the entire ask side. Other traders front-run you. You pay well above mid price. The alternative: Time-Weighted Average Price (TWAP) orders split large trades into smaller chunks over time. Break that $50,000 into 50 × $1,000 orders over 30 minutes. Each order has minimal impact. Average execution stays near mid price. Total slippage drops to 0.5–1% instead of 5–10%. Hyperliquid’s native TWAP implementation handles the mechanics—you specify total size and duration, the exchange executes incrementally. This guide shows you how to place TWAP orders and monitor execution via WebSocket.

TWAP order structure

TWAP orders aren’t yet available in the Python SDK’s high-level methods. According to the Hyperliquid API documentation, you need to use raw API calls with the twapOrder action type:
from hyperliquid.exchange import Exchange
from hyperliquid.utils.signing import get_timestamp_ms, sign_l1_action, float_to_wire
from eth_account import Account

wallet = Account.from_key(private_key)
exchange = Exchange(wallet, BASE_URL)

# For spot asset with index 5, asset ID = 10000 + 5 = 10005
twap_action = {
    "type": "twapOrder",
    "twap": {
        "a": 10005,                        # Asset ID (10000 + spot index)
        "b": True,                         # Buy (True) or Sell (False)
        "s": float_to_wire(100.0),         # Total size
        "r": False,                        # Reduce-only
        "m": 30,                           # Duration in minutes
        "t": False                         # Randomize timing
    }
}

timestamp = get_timestamp_ms()
signature = sign_l1_action(wallet, twap_action, exchange.vault_address,
                           timestamp, exchange.expires_after, False)
result = exchange._post_action(twap_action, signature, timestamp)

TWAP parameters explained

  • a (asset ID) — for spot: 10000 + index from spot metadata. For perps: use the perp index directly. Get index from info.spot_meta_and_asset_ctxs() or info.meta().
  • b (buy/sell)True for buy, False for sell. Boolean, not string.
  • s (size) — total size to execute. Use float_to_wire() to avoid trailing zeros that cause API rejections.
  • m (duration in minutes) — how long to spread execution. Range: 1–1440 (24 hours). Longer duration = lower market impact but more price exposure.
  • t (randomize)False = evenly spaced intervals (30-min TWAP → child orders every ~30 seconds). True = randomized intervals, harder for algorithms to detect.
  • r (reduce-only)True prevents opening new positions, only closes existing ones.

Understanding TWAP execution

When you submit a TWAP order, Hyperliquid’s exchange backend calculates child order sizing and timing, then executes incrementally. You receive WebSocket updates for each fill. Example with real numbers — 30-minute TWAP for 100 units, randomize=False:
  • Duration — 1800 seconds
  • Child order interval — ~30 seconds (1800 / 60 ≈ 30)
  • Child order size — ~1.67 units (100 / 60 ≈ 1.67)
  • Total child orders — ~60
If liquidity is thin and some child orders only partially fill, the TWAP adjusts remaining size across future child orders. Why randomize=True matters: In competitive markets, other algorithms detect regular 30-second intervals and front-run your orders. Randomization spreads child orders across the same total duration but at unpredictable intervals. Average still ~30 seconds, but actual intervals vary from 10–50 seconds.

Placing TWAP orders with validation

Hyperliquid enforces precision and minimum size rules. Each asset has szDecimals (size precision) and a $10 minimum notional value requirement:
from hyperliquid.utils.signing import get_timestamp_ms, sign_l1_action, float_to_wire

async def place_twap_order(
    exchange: Exchange,
    info: Info,
    symbol: str,
    is_buy: bool,
    total_size: float,
    duration_minutes: int,
    randomize: bool = False,
    reduce_only: bool = False
):
    # Get spot metadata and find asset
    spot_data = info.spot_meta_and_asset_ctxs()
    spot_meta = spot_data[0]

    target_pair = None
    for pair in spot_meta.get("universe", []):
        if pair.get("name") == symbol:
            target_pair = pair
            break

    if not target_pair:
        raise ValueError(f"Asset {symbol} not found")

    asset_index = target_pair.get("index")
    asset_id = 10000 + asset_index  # Spot asset ID

    # Validate size precision
    sz_decimals = target_pair.get("szDecimals", 8)
    valid_size = round(total_size, sz_decimals)

    # Build TWAP action
    twap_action = {
        "type": "twapOrder",
        "twap": {
            "a": asset_id,
            "b": is_buy,
            "s": float_to_wire(valid_size),
            "r": reduce_only,
            "m": duration_minutes,
            "t": randomize
        }
    }

    # Sign and send
    timestamp = get_timestamp_ms()
    signature = sign_l1_action(exchange.wallet, twap_action, exchange.vault_address,
                               timestamp, exchange.expires_after, False)
    result = exchange._post_action(twap_action, signature, timestamp)

    if result.get("status") == "ok":
        response_data = result.get("response", {}).get("data", {})
        status_info = response_data.get("status", {})
        if "running" in status_info:
            return status_info["running"]["twapId"]

    return None
Why float_to_wire()? Prevents trailing zeros that cause API rejections. 100.0 becomes "100" string format.
Each child order must meet the $10 minimum. A 30-minute TWAP creates ~60 child orders. If your TWAP is $500 total, each child = $500 / 60 ≈ $8.33 (rejected). Minimum safe TWAP: $600+ for 60 child orders.

Monitoring TWAP execution via WebSocket

According to the WebSocket API documentation, the userEvents channel delivers TWAP updates in the twapHistory array:
import websockets
import json

async def monitor_twap_execution(leader_address: str):
    async with websockets.connect(WS_URL) as websocket:
        subscription = {
            "method": "subscribe",
            "subscription": {"type": "userEvents", "user": leader_address}
        }
        await websocket.send(json.dumps(subscription))

        async for message in websocket:
            data = json.loads(message)
            if data.get("channel") == "user":
                for twap_event in data.get("data", {}).get("twapHistory", []):
                    await handle_twap_event(twap_event)
Each TWAP event contains state (order details) and status (execution phase):
async def handle_twap_event(twap_event: dict):
    state = twap_event.get("state", {})
    status = twap_event.get("status", {}).get("status", "unknown")

    coin = state.get("coin", "N/A")
    side = "BUY" if state.get("side") == "B" else "SELL"
    total_size = float(state.get("sz", "0"))
    executed_size = float(state.get("executedSz", "0"))
    executed_notional = state.get("executedNtl", "0")

    progress_pct = (executed_size / total_size * 100) if total_size > 0 else 0

    if status == "activated":
        print(f"TWAP ACTIVATED: {side} {total_size} {coin}")
    elif status == "completed":
        avg_price = float(executed_notional) / executed_size if executed_size > 0 else 0
        print(f"TWAP COMPLETED: {executed_size} {coin} @ avg \${avg_price:.2f}")
    elif status == "terminated":
        print(f"TWAP TERMINATED: {executed_size}/{total_size} ({progress_pct:.1f}%)")
    elif status == "canceled":
        print(f"TWAP CANCELED: Partial fill {executed_size}/{total_size}")

    return {"coin": coin, "status": status, "progress_pct": progress_pct}

TWAP status lifecycle

  • activated — TWAP accepted by exchange, child order execution begins.
  • completed — all size executed successfully.
  • terminated — exchange stopped execution early:
    • Insufficient balance — account ran out of funds mid-execution
    • Market conditions — extreme volatility or order book issues
    • Execution failure — child orders consistently rejected
  • canceled — you manually stopped the TWAP using the twapCancel API action.
During execution, you’ll also receive individual fill events in the userEvents.fills array for each child order. Correlate these with the active TWAP to track real-time progress.

Canceling TWAP orders

Cancel active TWAPs using raw API with twapCancel action:
from hyperliquid.utils.signing import get_timestamp_ms, sign_l1_action

def cancel_twap_order(exchange: Exchange, asset_id: int, twap_id: int):
    twap_cancel_action = {
        "type": "twapCancel",
        "a": asset_id,      # Same asset ID used when placing (10000 + index for spot)
        "t": twap_id        # TWAP ID from placement response
    }

    timestamp = get_timestamp_ms()
    signature = sign_l1_action(exchange.wallet, twap_cancel_action,
                               exchange.vault_address, timestamp,
                               exchange.expires_after, False)
    result = exchange._post_action(twap_cancel_action, signature, timestamp)

    if result.get("status") == "ok":
        response_data = result.get("response", {}).get("data", {})
        return response_data.get("status") == "success"

    return False
You can only cancel TWAPs still executing (status activated in WebSocket events). Completed or terminated TWAPs return an error.

Tracking child order fills

Individual child order fills appear in userEvents.fills alongside regular orders. Correlate with active TWAPs:
async def handle_user_events(data: dict, active_twaps: dict):
    for fill in data.get("data", {}).get("fills", []):
        coin = fill.get("coin")
        if coin in active_twaps:
            size = float(fill.get("sz"))
            price = float(fill.get("px"))

            # Track for VWAP calculation
            active_twaps[coin]["fills"].append({"size": size, "price": price})

            # Calculate running VWAP
            total_notional = sum(f["size"] * f["price"] for f in active_twaps[coin]["fills"])
            total_size = sum(f["size"] for f in active_twaps[coin]["fills"])
            vwap = total_notional / total_size if total_size > 0 else 0

            print(f"TWAP child fill: {size} {coin} @ \${price} (VWAP: \${vwap:.2f})")

Reduce-only TWAPs for position exits

Set "r": True in the TWAP action to prevent accidentally increasing positions when closing:
twap_action = {
    "type": "twapOrder",
    "twap": {
        "a": 0,             # BTC perp index
        "b": False,         # Sell to close long
        "s": float_to_wire(10.0),
        "r": True,          # Reduce-only
        "m": 60,
        "t": False
    }
}
You hold a 10 BTC long position. You place a reduce-only TWAP sell for 15 BTC. The TWAP stops after selling 10 BTC—it won’t open a 5 BTC short position. Without "r": True, you’d flip from 10 BTC long to 5 BTC short.

Mirroring TWAP orders in copy trading

Copy trading with TWAP orders presents unique challenges compared to regular limit orders. The fundamental issue: TWAP IDs are asymmetric. When you place a TWAP, the API response includes the TWAP ID:
result = exchange._post_action(twap_action, signature, timestamp)
response_data = result.get("response", {}).get("data", {})
status_info = response_data.get("status", {})

if "running" in status_info:
    twap_id = status_info["running"]["twapId"]  # You get the ID here
But when monitoring via WebSocket, the twapHistory events don’t include the TWAP ID:
for twap_event in data.get("data", {}).get("twapHistory", []):
    state = twap_event.get("state", {})
    # state contains: coin, side, sz, minutes, randomize
    # state does NOT contain: twapId
According to the WebSocket API documentation, twapHistory events contain a state object with TWAP properties but no twapId field for tracking. This asymmetry creates problems for copy trading:
  • You can’t use TWAP IDs to deduplicate events (no ID in WebSocket)
  • You can’t map leader TWAP IDs to follower TWAP IDs (you never see leader’s ID)
  • You can cancel your own TWAPs (you have the follower TWAP ID from placement), but you need a different strategy to know when to cancel based on leader activity
The solution: Use TWAP properties as composite keys instead of relying on IDs.

The combination-based tracking strategy

According to the WebSocket API documentation, twapHistory events in the userEvents channel contain a state object with TWAP properties but no unique identifier for tracking. The solution: Create combination keys from TWAP properties to detect duplicates:
def create_leader_twap_combination(state: dict) -> str:
    """Create leader TWAP combination ID with original size"""
    coin_field = state.get("coin", "")
    side = state.get("side")  # "B" for buy, "A" for sell
    minutes = state.get("minutes", 1)
    randomize = state.get("randomize", False)
    size = state.get("sz", "0")

    return f"{coin_field}_{side}_{minutes}_{randomize}_{size}"

leader_twap_combinations: set = set()  # Track processed leader TWAPs

# In event handler
leader_combination = create_leader_twap_combination(state)

if leader_combination in leader_twap_combinations:
    # Already processed this TWAP - skip
    continue

leader_twap_combinations.add(leader_combination)
A leader might place two identical 30-minute BTC TWAPs with different sizes (e.g., 0.5 BTC and 1.0 BTC). Without size in the combination, they’d have the same key and you’d only mirror one.

Handling same-wallet testing scenarios

This section describes a development shortcut for testing. In production with separate leader and follower wallets, this infinite loop problem won’t exist.
When testing with the same wallet as both leader and follower, you need to prevent mirroring your own follower TWAPs. But since WebSocket events don’t include TWAP IDs, you can’t simply check “is this TWAP ID in my follower list?” The solution: Track follower TWAP combinations separately with adjusted sizes:
follower_twap_combinations: set = set()  # Track our placed follower TWAPs

def create_follower_twap_combination(coin_field: str, side: str, minutes: int,
                                      randomize: bool, follower_size: float) -> str:
    """Create follower TWAP combination ID with adjusted size"""
    return f"{coin_field}_{side}_{minutes}_{randomize}_{follower_size}"

# After placing follower TWAP
follower_combination = create_follower_twap_combination(
    coin_field, side, minutes, randomize, follower_total_size
)
follower_twap_combinations.add(follower_combination)
Leader combination uses leader’s size, follower combination uses follower’s (potentially different) size. When a WebSocket event arrives, you check both:
1

Check if processed as leader

Is this combination in leader_twap_combinations? If yes, already processed as leader TWAP.
2

Check if own follower order

Is this combination in follower_twap_combinations? If yes, this is our own follower TWAP—skip it.
async def handle_leader_twap_events(data: dict, exchange: Exchange, info: Info):
    for twap_event in data.get("data", {}).get("twapHistory", []):
        state = twap_event.get("state", {})
        twap_status = twap_event.get("status", {}).get("status", "unknown")

        # Check if this is our own follower TWAP
        try:
            side = state.get("side")
            minutes = state.get("minutes", 1)
            randomize = state.get("randomize", False)
            current_size = float(state.get("sz", "0"))
            coin_field = state.get("coin", "")

            potential_follower_combination = create_follower_twap_combination(
                coin_field, side, minutes, randomize, current_size
            )

            if potential_follower_combination in follower_twap_combinations:
                # Skip our own follower TWAP
                continue
        except (ValueError, TypeError):
            pass

        leader_combination = create_leader_twap_combination(state)

        if twap_status == "activated":
            if leader_combination in leader_twap_combinations:
                # Already processed
                continue

            leader_twap_combinations.add(leader_combination)

            # Mirror the TWAP with adjusted sizing
            follower_twap_id = await place_follower_twap_order(
                exchange, info, twap_event, leader_combination
            )
In production with separate wallets, leader and follower sizes don’t overlap in the same WebSocket feed—you don’t need this follower combination check. This is purely for development convenience.

Placing follower TWAPs with adjusted sizing

When mirroring a leader’s TWAP, you need to maintain the same time parameters (duration, randomization) while adjusting size based on your capital allocation:
async def place_follower_twap_order(
    exchange: Exchange, info: Info, leader_twap_data: dict, leader_combination: str
) -> Optional[int]:
    """Place corresponding follower TWAP order with adjusted sizing"""

    state = leader_twap_data.get("state", {})
    coin_field = state.get("coin", "")
    side = state.get("side")  # "B" or "A"
    minutes = state.get("minutes", 1)
    randomize = state.get("randomize", False)
    reduce_only = state.get("reduceOnly", False)

    # Get current price for sizing calculation
    asset_info = await get_spot_asset_info(info, coin_field)
    if not asset_info:
        return None

    # Calculate follower size based on fixed USDC allocation
    follower_total_size = round(
        FIXED_ORDER_VALUE_USDC / asset_info["price"],
        asset_info["szDecimals"]
    )

    # Get asset index for API call
    if coin_field.startswith("@"):
        asset_index = int(coin_field[1:])
    else:
        # Lookup index for TOKEN/USDC format
        spot_meta = info.spot_meta()
        pair_info = next(p for p in spot_meta["universe"] if p["name"] == coin_field)
        asset_index = pair_info["index"]

    # Place TWAP with same time parameters, adjusted size
    twap_action = {
        "type": "twapOrder",
        "twap": {
            "a": 10000 + asset_index,  # Spot asset ID
            "b": side == "B",  # Buy/sell
            "s": float_to_wire(follower_total_size),
            "r": reduce_only,
            "m": minutes,  # Same duration as leader
            "t": randomize  # Same randomization as leader
        }
    }

    timestamp = get_timestamp_ms()
    signature = sign_l1_action(exchange.wallet, twap_action, exchange.vault_address,
                               timestamp, exchange.expires_after, False)
    result = exchange._post_action(twap_action, signature, timestamp)

    if result and result.get("status") == "ok":
        response_data = result.get("response", {}).get("data", {})
        status_info = response_data.get("status", {})

        if "running" in status_info:
            follower_twap_id = status_info["running"]["twapId"]

            # Track follower combination to prevent re-mirroring
            follower_combination = create_follower_twap_combination(
                coin_field, side, minutes, randomize, follower_total_size
            )
            follower_twap_combinations.add(follower_combination)

            return follower_twap_id

    return None
The leader chose those parameters based on market conditions and their execution strategy. If the leader uses 30 minutes with randomization, they’re likely trading in a market where that timing avoids front-running. Your follower TWAP benefits from the same strategy.

Canceling follower TWAPs when leader cancels

When a leader cancels or terminates a TWAP, you need to cancel the corresponding follower TWAP. What you have:
  • Follower TWAP ID (from placement response — stored when you placed it)
  • Leader TWAP combination (from WebSocket event properties)
What you need:
  • A way to map: “when this leader combination cancels → cancel this follower TWAP ID”
Use combination-based mappings to bridge the gap:
twap_mappings: Dict[str, int] = {}  # leader_combination -> follower_twap_id

# After placing follower TWAP
twap_mappings[leader_combination] = follower_twap_id  # Store the ID you got from placement

# When leader TWAP is canceled/terminated
if twap_status in ["canceled", "terminated"]:
    if leader_combination in twap_mappings:
        follower_twap_id = twap_mappings[leader_combination]  # Retrieve the stored ID
        # Now you can cancel using the TWAP ID you saved earlier
        await cancel_follower_twap_order(exchange, info, follower_twap_id, coin_field)

        # Clean up tracking
        del twap_mappings[leader_combination]
        leader_twap_combinations.discard(leader_combination)

        # Remove follower combination (find matching entry)
        for follower_combo in list(follower_twap_combinations):
            if follower_combo.startswith(f"{coin_field}_{side}_{minutes}_{randomize}_"):
                follower_twap_combinations.discard(follower_combo)
                break
If the leader places the same TWAP combination later (same asset, same parameters, same size), you want to mirror it again. Removing it from leader_twap_combinations allows reprocessing.

When to mirror vs when to skip

Not all leader TWAPs should be mirrored. Consider these scenarios: Mirror when:
  • TWAP status is activated (new TWAP just started)
  • Asset is spot (unless you specifically want to mirror perp TWAPs)
  • Combination hasn’t been processed yet
  • You have sufficient balance for the follower TWAP
Skip when:
  • TWAP status is completed (already finished, no action needed)
  • Combination is in leader_twap_combinations (already mirrored)
  • Combination is in follower_twap_combinations (this is your own follower TWAP)
  • Asset is perpetual and you only mirror spot trades
  • TWAP size is too small to meet minimum notional after adjusting for your allocation
Cancel follower TWAP when:
  • Leader TWAP status changes to canceled or terminated
  • You have a corresponding follower TWAP tracked in twap_mappings
This logic ensures you mirror leader strategy changes (canceling TWAPs early) while avoiding duplicate mirrors and infinite loops.

Key takeaways

Reduces market impact and slippage by executing incrementally.
TWAP functionality requires twapOrder and twapCancel actions with manual signing. The SDK doesn’t provide high-level TWAP methods yet.
Spot assets: 10000 + index. Perps: use index directly. Get index from info.spot_meta_and_asset_ctxs() or info.meta().
Subscribe to userEvents before placing TWAPs to catch activation, fills, and completion events via twapHistory array.
Each child must meet $10 minimum notional. Calculate: (total TWAP size / estimated child orders) × price > $10. Minimum safe TWAP: $600 for 30-minute duration.
You receive TWAP IDs when placing orders but NOT in WebSocket events. Use combination keys (coin_side_minutes_randomize_size) to track leader TWAPs, then map combinations to follower TWAP IDs for cancellation.
The complete implementation is available in the hyperliquid-examples repository.

See also

I