Hyperliquid rejects orders with wrong precision. BTC requires 5 decimal places for size, PURR needs 0 (whole integers only), and others vary from 0 to 8. If you hardcode precision, your bot breaks when trading new assets. This guide shows you how to query metadata and calculate valid order sizes dynamically.
The precision problem
This order gets rejected:
# Wrong: BTC spot order with 3 decimals
exchange.order(
name="@0", # BTC spot
is_buy=True,
sz=0.001, # Rejected: BTC spot needs exactly 5 decimals
limit_px=65000.0,
order_type={"limit": {"tif": "Gtc"}}
)
Error: "Tick size violation" or "Invalid order size"
This works:
# Correct: BTC spot with 5 decimals
exchange.order(
name="@0",
is_buy=True,
sz=0.00100, # Valid: 5 decimal places
limit_px=65000.0,
order_type={"limit": {"tif": "Gtc"}}
)
Hardcoding 0.00100 breaks when you switch to another asset. You need dynamic precision.
Tick size vs lot size
Hyperliquid enforces precision on both price and quantity:
Tick size — price precision:
- Prices limited to 5 significant figures
- Max decimal places:
MAX_DECIMALS - szDecimals, where MAX_DECIMALS is 6 for perps and 8 for spot
- Integer prices are always valid regardless of significant figures (for example,
123456 is valid for BTC)
- Examples for BTC perp (
szDecimals = 5, so max 1 decimal place): 97000.5 ✓, 97000.55 ✗
Lot size — order quantity precision:
- Determined by
szDecimals in asset metadata, fixed per asset at deployment
- Ranges from 0 (whole integers only, for example, PURR) to 5 (for example, BTC)
- Must query from API—cannot hardcode
Both are enforced at the API level. Violating either causes immediate order rejection.
Hyperliquid uses multiple formats to identify assets:
Spot assets:
@{index} — direct index reference (for example, @0 = BTC/USDC)
{TOKEN}/USDC — pair name (for example, PURR/USDC)
Perpetual assets:
{SYMBOL} — direct symbol (for example, BTC, ETH, SOL)
You need to handle all three formats when querying metadata.
Query spotMetaAndAssetCtxs for comprehensive spot data including precision requirements:
from hyperliquid.info import Info
def get_spot_asset_info(info: Info, coin_field: str) -> dict:
"""Get price and size decimals for spot assets"""
# Get both metadata and live contexts
spot_data = info.spot_meta_and_asset_ctxs()
spot_meta = spot_data[0] # Metadata (universe, tokens)
asset_ctxs = spot_data[1] # Live data (prices, volumes)
if coin_field.startswith("@"):
# Handle @index format
index = int(coin_field[1:])
if index >= len(asset_ctxs):
raise ValueError(f"Invalid spot index: {coin_field}")
# Get current price (try midPx first, fallback to markPx)
ctx = asset_ctxs[index]
price = float(ctx.get("midPx", ctx.get("markPx", 0)))
if price <= 0:
raise ValueError(f"No price available for {coin_field}")
# Find pair metadata
pair_info = next(
(p for p in spot_meta["universe"] if p["index"] == index),
None
)
if not pair_info:
raise ValueError(f"No metadata for {coin_field}")
# Get base token size decimals
token_indices = pair_info["tokens"]
base_token_index = token_indices[0]
token_info = spot_meta["tokens"][base_token_index]
size_decimals = token_info.get("szDecimals", 6)
return {
"coin": coin_field,
"price": price,
"szDecimals": size_decimals,
"name": pair_info.get("name", coin_field)
}
elif "/" in coin_field:
# Handle PAIR/USDC format - convert to @index
pair = next(
(p for p in spot_meta["universe"] if p["name"] == coin_field),
None
)
if not pair:
raise ValueError(f"Pair not found: {coin_field}")
# Get info using @index format
return get_spot_asset_info(info, f"@{pair['index']}")
else:
raise ValueError(f"Unsupported spot format: {coin_field}")
Perps use a simpler structure via metaAndAssetCtxs:
def get_perp_asset_info(info: Info, symbol: str) -> dict:
"""Get price and size decimals for perpetual contracts"""
meta_and_contexts = info.meta_and_asset_ctxs()
meta = meta_and_contexts[0]
asset_ctxs = meta_and_contexts[1]
# Find asset in universe
asset_index = None
for i, asset in enumerate(meta["universe"]):
if asset["name"] == symbol:
asset_index = i
break
if asset_index is None:
raise ValueError(f"Perp asset not found: {symbol}")
# Get metadata
asset_meta = meta["universe"][asset_index]
size_decimals = asset_meta.get("szDecimals", 8)
# Get current price
ctx = asset_ctxs[asset_index]
price = float(ctx.get("markPx", "0"))
if price <= 0:
raise ValueError(f"No price for {symbol}")
return {
"coin": symbol,
"price": price,
"szDecimals": size_decimals,
"name": asset_meta["name"]
}
Working with order precision
Calculating valid order sizes (lot size)
Order sizes must be rounded to the szDecimals specified in asset metadata. This is also known as the lot size—the minimum increment for order quantities.
import math
def calculate_order_size(target_value_usdc: float, asset_info: dict) -> float:
"""Calculate order size with correct precision (lot size)"""
price = asset_info["price"]
size_decimals = asset_info["szDecimals"]
# Calculate raw size
raw_size = target_value_usdc / price
# Floor-truncate to valid precision (never round up beyond balance)
valid_size = math.floor(raw_size * 10**size_decimals) / (10**size_decimals)
return valid_size
Use math.floor, not round, for size truncation. Rounding up can produce a size larger than your balance, causing order rejection. This matches the official SDK rounding example.
Example (from the copy trading implementation):
- Target value: $15 USDC
- BTC spot price: $65,000
- Size decimals (
szDecimals): 5
- Raw size: 15 / 65000 = 0.000230769…
- Valid size:
floor(0.000230769 * 100000) / 100000 = 0.00023 (5 decimals)
The szDecimals field in metadata determines lot size precision. Query this value from spot_meta_and_asset_ctxs() for spot or meta_and_asset_ctxs() for perps.
Minimum notional value
Hyperliquid enforces minimum order values of $10 (or 10 USDC for spot). The only exception is exactly closing a position with a reduce-only order. Check before placing:
def validate_order_size(size: float, price: float, min_notional: float = 10.0) -> bool:
"""Verify order meets minimum notional value"""
notional_value = size * price
if notional_value < min_notional:
raise ValueError(
f"Order value \${notional_value:.2f} below minimum \${min_notional}"
)
return True
Price precision (tick size)
Price precision on Hyperliquid follows specific rules:
Significant figures rule — prices can have up to 5 significant figures
Integer exception — integer prices are always valid regardless of significant figures (for example, 123456 is valid for BTC even though it has 6 significant figures)
Maximum decimal places — determined by the formula MAX_DECIMALS - szDecimals:
- Perpetuals —
MAX_DECIMALS = 6
- Spot —
MAX_DECIMALS = 8
This means the allowed price decimal places depend on the asset. Examples for perpetuals:
| Asset | szDecimals | Max price decimals (6 - szDecimals) | Example valid price |
|---|
| BTC | 5 | 1 | 97000.5 |
| ETH | 4 | 2 | 2567.35 |
| BNB | 3 | 3 | 625.123 |
| ATOM | 2 | 4 | 9.1234 |
| DYDX | 1 | 5 | 1.01234 |
| TRX | 0 | 6 | 0.123456 |
Examples of valid and invalid perpetual prices (BTC, szDecimals = 5):
97000.5 ✓ (5 sig figs, 1 decimal)
97000.55 ✗ (exceeds 6 - 5 = 1 max decimal places)
123456 ✓ (integer exception—always valid)
Examples of valid and invalid perpetual prices (general):
1234.5 ✓ (5 significant figures)
1234.56 ✗ (6 significant figures—too many)
0.001234 ✓ (4 significant figures, 6 decimals)
Price formatting for API:
def format_price(price: float, sz_decimals: int, is_spot: bool = False) -> float:
"""Format price matching the official SDK rounding logic.
Uses 5 significant figures with MAX_DECIMALS - szDecimals decimal places.
Integer prices above 100,000 are always valid.
"""
max_decimals = 8 if is_spot else 6
if price > 100_000:
# Integer prices always valid regardless of sig figs
return round(price)
# 5 significant figures, then cap decimal places
return round(float(f"{price:.5g}"), max_decimals - sz_decimals)
# Examples
format_price(97123.456, sz_decimals=5) # 97123.0 (BTC perp, max 1 decimal)
format_price(2567.891, sz_decimals=4) # 2567.9 (ETH perp, max 2 decimals)
format_price(0.12345678, sz_decimals=2, is_spot=True) # 0.12346 (spot, max 6 decimals)
When signing orders, trailing zeros in price and size strings cause the signature hash to change, producing the misleading error "User or API Wallet 0x1a2B3c4D5e6F7a8B9c0D1e2F3a4B5c6D7e8F9a0b does not exist." The SDK handles this automatically via float_to_wire(), which uses Python’s Decimal.normalize() to strip trailing zeros. If you build order wire messages manually, strip trailing zeros before signing.
Implementation examples
Complete order placement flow
Putting it together for dynamic multi-asset trading:
def place_dynamic_order(
exchange: Exchange,
info: Info,
coin_field: str,
is_buy: bool,
target_value_usdc: float,
price_offset_pct: float = 0
):
"""Place order with automatic precision handling"""
# Determine asset type
is_spot = coin_field.startswith("@") or "/" in coin_field
# Get asset info
if is_spot:
asset_info = get_spot_asset_info(info, coin_field)
else:
asset_info = get_perp_asset_info(info, coin_field)
# Calculate order size with proper precision
order_size = calculate_order_size(target_value_usdc, asset_info)
# Validate minimum notional
validate_order_size(order_size, asset_info["price"])
# Calculate limit price with offset
market_price = asset_info["price"]
offset_multiplier = 1 + (price_offset_pct / 100)
raw_price = market_price * offset_multiplier
# Round price to tick size using the correct formula
limit_price = format_price(raw_price, asset_info["szDecimals"], is_spot)
print(f"Placing order: {order_size} {coin_field} @ \${limit_price}")
# Place order
result = exchange.order(
name=coin_field,
is_buy=is_buy,
sz=order_size,
limit_px=limit_price,
order_type={"limit": {"tif": "Gtc"}},
reduce_only=False
)
return result
Handling unknown assets
When trading new or obscure assets:
from typing import Optional
def get_asset_info_safe(info: Info, coin_field: str) -> Optional[dict]:
"""Get asset info with graceful fallback"""
try:
if coin_field.startswith("@") or "/" in coin_field:
return get_spot_asset_info(info, coin_field)
else:
return get_perp_asset_info(info, coin_field)
except Exception as e:
print(f"Warning: failed to get metadata for {coin_field}: {e}")
# Fallback to safe defaults
return {
"coin": coin_field,
"price": None,
"szDecimals": 8, # Conservative default (most restrictive)
"name": coin_field
}
Use conservative defaults when metadata is unavailable, but always warn users. A higher szDecimals default is safer because it produces a more precisely truncated (smaller) size via math.floor.
Query metadata once, reuse for multiple orders:
class AssetMetadataCache:
def __init__(self, info: Info):
self.info = info
self.cache = {}
def get_asset_info(self, coin_field: str) -> dict:
if coin_field not in self.cache:
if coin_field.startswith("@") or "/" in coin_field:
self.cache[coin_field] = get_spot_asset_info(
self.info, coin_field
)
else:
self.cache[coin_field] = get_perp_asset_info(
self.info, coin_field
)
return self.cache[coin_field]
def invalidate(self, coin_field: str = None):
"""Clear cache for asset or all assets"""
if coin_field:
self.cache.pop(coin_field, None)
else:
self.cache.clear()
Refresh cache periodically or when orders get rejected.
Troubleshooting
Common rejection reasons
“Order has invalid size” (lot size violation) — size is not a multiple of 10^(-szDecimals). Query szDecimals from metadata and floor-truncate order size accordingly.
“Price must be divisible by tick size” (price precision) — price has too many significant figures (>5) or exceeds MAX_DECIMALS - szDecimals decimal places. Use the format_price function above.
“Order must have minimum value of 10 USDC” — below minimum notional value. Increase order size. Exception: exactly closing a position with reduce-only bypasses this minimum.
“User or API Wallet 0x1a2B3c4D5e6F7a8B9c0D1e2F3a4B5c6D7e8F9a0b does not exist” — often caused by trailing zeros in price or size strings during signing. For example, "0.2300" fails where "0.23" succeeds. If you build order wire messages manually, use Decimal.normalize() to strip trailing zeros before signing. The SDK’s float_to_wire() handles this automatically.
“Asset not found” — wrong coin field format (@index for spot, SYMBOL for perps) or delisted asset.
“Insufficient balance” — not a precision issue, but verify wallet has enough collateral before placing orders.
Summary
Query szDecimals from asset metadata to calculate valid order sizes dynamically—never hardcode precision. Floor-truncate sizes to szDecimals decimal places. Prices must have ≤5 significant figures with max MAX_DECIMALS - szDecimals decimal places (where MAX_DECIMALS is 6 for perps, 8 for spot); integer prices are always valid. Support all asset formats (@index, PAIR/USDC, SYMBOL), validate minimum notional ($10 USDC), and cache metadata to reduce API calls.
The complete implementation is available in the Chainstack Hyperliquid trading bot repository.