> ## Documentation Index
> Fetch the complete documentation index at: https://perps-api.vooi.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Preparing orders

> Round price and size to each venue's precision and meet its minimum before submitting an order

Each venue enforces its own tick, lot, and minimum rules. The API checks your order against them **before** it reaches the venue: an invalid value is rejected with a `400` that names the offending field and, for precision mismatches, the exact normalized value to resubmit (see [Errors](/docs/errors)). Preparing the order on your side is still worth doing — it saves the round-trip and gives you exact control over the values that trade — but it is now an optimization, not the only guard.

<Info>
  **Prerequisites:** an active VOOI API token and at least one connected exchange. See [API Tokens](/docs/tokens) and [Use your own keys](/docs/exchanges).
</Info>

## Server-side validation and `normalizeParams`

[`POST /exchange/orders`](/docs/api-reference/exchange/create-a-new-order) accepts an optional boolean `normalizeParams` that controls what happens when your price or size doesn't match the market's tick or step:

* **`normalizeParams: false` (the default)** — the order is rejected with a `400` whose per-field `errors` array includes the normalized value to resubmit, e.g. `Size "5.1911" does not match the market step size (0.01). Use the normalized value: 5.19`.
* **`normalizeParams: true`** — the API rounds `price`, `size`, and trigger prices to the market's rules for you and submits the normalized order.

Minimums, maximums, notional limits, and price bands are hard errors either way — they are never auto-fixed. Keep the flag off when you need to know exactly what values will trade; turn it on when a best-effort fit is acceptable.

## The recipe

These five steps produce an order any venue accepts. Round the price *before* deriving size, so the size matches the price you'll actually trade at. Do every calculation with an arbitrary-precision decimal type, never floating-point — rounding a price with a binary float reintroduces the exact errors these rules exist to prevent.

The example under each step opens roughly **\$200 of `HYPE` on Hyperliquid** with a limit bid, using illustrative values from the market row (`priceDecimals: 4`, `baseDecimals: 2`, `quoteDecimals: 2`). Substitute the real values your row returns.

1. **Read the market row.** Fetch [`GET /exchange/markets`](/docs/api-reference/exchange/get-available-trading-markets) and find the `(exchange, baseSymbol, quoteSymbol)` row you're trading. The constraint fields you need are `priceDecimals`, `baseDecimals`, `quoteDecimals`, the current `price`, and the venue-specific `data` extras (see [Exchange-specific metadata](/docs/trading-markets#exchange-specific-metadata-data)).
2. **Round the price down to the tick.** For a limit order, round your chosen `price` down to `priceDecimals` decimal places; for a market order, skip this and use the row's `price`. Hyperliquid has an extra rule — see [Price and size precision](#price-and-size-precision) below.<br />*Example: you want to bid `38.5274`. That's within `priceDecimals` (4), but Hyperliquid caps a price at 5 significant figures, so it becomes `38.527`.*
3. **Derive size from your notional.** Floor your USD amount to `quoteDecimals`, then divide by the price from step 2 — the rounded limit price, or the row's `price` for a market order. The result is the base-currency `size`.<br />*Example: `200.00 ÷ 38.527 = 5.1911…` HYPE.*
4. **Round the size down to the lot step.** Round to the venue's step (`data.stepSize` on Aster and Binance, `data.tradingConfig.minOrderSizeChange` on Extended) or to `baseDecimals` where no explicit step is published. **If the size rounds to `0`, stop** — the order is too small to represent and must not be submitted.<br />*Example: round `5.1911` down to `baseDecimals` (2) → `5.19` HYPE.*
5. **Check the minimum, then submit.** Confirm the order clears the venue's minimum (see [Minimum order size](#minimum-order-size)), then send [`POST /exchange/orders`](/docs/api-reference/exchange/create-a-new-order).<br />*Example: `5.19 × 38.527 ≈ \$200`, well above Hyperliquid's \$10 minimum. Submit `price: "38.527"`, `size: "5.19"`.*

## Price and size precision

Every venue enforces a price tick and a size step, and the API applies them uniformly: with `normalizeParams: false` (the default) an off-tick price or off-step size is rejected with a `400` naming the field and the normalized value; with `normalizeParams: true` both are rounded for you. The per-market values live on the [`/exchange/markets`](/docs/api-reference/exchange/get-available-trading-markets) row — `priceDecimals`/`baseDecimals` everywhere, plus explicit steps in the venue-specific `data` (`data.stepSize` on Aster and Binance, `data.tradingConfig.minPriceChange`/`minOrderSizeChange` on Extended, `data.qtyStep` on Bybit, `data.baseIncrement`/`data.quoteIncrement` on Ondo).

The safe default is still to round both `price` and `size` yourself for every venue — it always produces a valid order and no round-trip is wasted.

<Note>
  **Hyperliquid prices carry an extra rule.** Beyond `priceDecimals`, a Hyperliquid price may use **at most 5 significant figures**, and at most `max(6 − baseDecimals, 0)` decimal places. Integer prices are always allowed regardless of significant figures. A price that breaks either limit is rejected, not snapped — for example `12345.6` (6 significant figures) is invalid, while `12345` and `1234.5` are fine. Hyperliquid treats the row's `baseDecimals` as its `szDecimals` for these calculations. A too-precise Hyperliquid price or size is caught before the venue and comes back with a readable precision message stating which decimals or significant-figure limit it broke.
</Note>

<Note>
  **Extended rounds to a price *step*, not just decimals.** Extended publishes a price tick as `data.tradingConfig.minPriceChange`. When that tick isn't a power of ten (e.g. `0.5`), rounding only to `priceDecimals` leaves the price off-tick. Round the price down to the `minPriceChange` step, the same way you round size to `minOrderSizeChange`. Extended prices are forwarded to the venue as you send them, so the snapping has to happen on your side.
</Note>

## Minimum order size

An undersized order is rejected with a readable `400` — e.g. `Order must have minimum value of $10` on Hyperliquid, or `Order notional <x> is below the market minimum <y>` on Lighter — regardless of the `normalizeParams` setting (minimums are never auto-fixed). Each venue defines its own minimum, and what the market row exposes about it differs per exchange:

| Exchange | Venue minimum | Exposed in the market row? | How to satisfy it |
| - | - | - | - |
| **Aster** | Per-symbol minimum notional (USD) and minimum quantity | **Yes** — in `data.filters` | Read the `MIN_NOTIONAL` filter's `notional` and the `LOT_SIZE` filter's `minQty`; size at or above both |
| **Hyperliquid** | Flat **\$10** order value (notional), platform-wide | No | Keep notional ≥ \$10; the row carries no minimum field |
| **Lighter** / **Robinhood** | Per-market minimum notional, plus a minimum base size | **Partly** — `data.minBaseAmount` carries the base-size floor; the notional minimum is not exposed | Size at or above `data.minBaseAmount`, and expect a readable `400` naming the notional minimum when an order is still too small |
| **Extended** | Per-market minimum size (base units) | **Yes** — `data.tradingConfig.minOrderSize` | Size at or above `minOrderSize`, rounded to `minOrderSizeChange` |
| **Binance** | Per-symbol minimum notional (USD) | **Yes** — in `data.filters` | Read the `MIN_NOTIONAL` filter's `notional` |
| **Bybit** | Per-market minimum notional and minimum quantity | **Yes** — `data.minNotionalValue` and `data.minOrderQty` | Size at or above both |
| **MEXC** | Per-contract minimum quantity (base units) | **Yes** — `data.minVol`, in contracts | Multiply `minVol` by `data.contractSize` to get base units |
| **Ondo** | None | — | Only the lot step (`data.baseIncrement`) and a size above zero are enforced |

<Note>
  The per-symbol values on Aster, Binance, Extended, Bybit, MEXC, and Lighter are read from the live market row and can change — always read them from the current [`GET /exchange/markets`](/docs/api-reference/exchange/get-available-trading-markets) response rather than hard-coding a number. The Hyperliquid \$10 figure is a fixed platform rule.
</Note>

<Note>
  **Closing a dust position.** Setting `reduceOnly: true` waives the minimum entirely on Hyperliquid, Lighter, Robinhood, Aster, Extended, Binance, and Bybit, so a position left below the venue minimum can still be closed in full. MEXC is the exception — its minimum applies to reduce-only orders too.
</Note>

A pragmatic shortcut many clients use is a single blanket floor — refuse any order below roughly \$10 of notional before it ever reaches a venue. That one guard covers Hyperliquid's \$10 rule and is comfortably above the typical Aster and Extended minimums, so it prevents the most common "too small" rejection without per-venue logic. Use the per-venue values above when you need to trade closer to the true minimum.

## Worked example: a \$5 order on Hyperliquid

A \$5 long on `HYPE` falls below Hyperliquid's \$10 minimum order value. The API rejects it before it reaches the venue with a readable `400`:

```json theme={null}
{
  "errors": [{ "message": "Order must have minimum value of $10", "path": ["size"] }],
  "message": "hyperliquid: Order validation failed",
  "statusCode": 400
}
```

Preparing the order catches this even earlier, without a round-trip. At **step 5** you compare the \$5 notional against Hyperliquid's \$10 minimum, see that it falls short, and either raise the size to clear \$10 or stop the order with a clear message of your own.

## Putting it in code

This prepares an order for **any** venue: round the price, derive the size from your notional, round the size to the lot step, and check the minimums — all before the order reaches the venue. The `market` object is built from the [`/exchange/markets`](/docs/trading-markets) row, with the per-venue lot step and minimums read from its `data` field. Use an arbitrary-precision decimal type throughout; the same logic ports across languages.

Read the market row (public, no authentication required):

```bash theme={null}
curl https://perps-api.vooi.io/exchange/markets
```

Round and size the order from the fields on that row:

<CodeGroup>
  ```python Python theme={null}
  from decimal import Decimal, ROUND_DOWN


  def truncate(value: Decimal, decimals: int) -> Decimal:
      """Round value DOWN to `decimals` decimal places."""
      return value.quantize(Decimal(1).scaleb(-decimals), rounding=ROUND_DOWN)


  def decimal_str(value: Decimal) -> str:
      """Plain decimal string, never scientific notation."""
      return format(value, "f")


  def round_price(price: Decimal, market: dict) -> Decimal:
      if market["exchange"] == "hyperliquid":
          if price == price.to_integral_value():
              return price                          # integer prices are always valid
          price = truncate(price, max(6 - market["baseDecimals"], 0))
          return truncate(price, 5 - 1 - price.adjusted())  # cap at 5 significant figures
      return truncate(price, market["priceDecimals"])


  def round_size(size: Decimal, market: dict) -> Decimal:
      step = market.get("stepSize")                 # Aster / Extended publish a lot step
      if step is not None:
          step = Decimal(step)
          rounded = truncate(size / step, 0) * step
      else:                                         # Hyperliquid / Lighter: round to decimals
          rounded = truncate(size, market["baseDecimals"])
      if rounded == 0 and size != 0:
          raise ValueError("size truncates to 0 — order is too small")
      return rounded


  def prepare_order(notional: Decimal, limit_price: Decimal, market: dict) -> dict:
      price = round_price(limit_price, market)
      size = round_size(truncate(notional, market["quoteDecimals"]) / price, market)
      min_size = market.get("minSize")
      if min_size is not None and size < Decimal(min_size):
          raise ValueError(f"size {decimal_str(size)} below the {min_size} minimum")
      if size * price < Decimal(market["minNotional"]):
          raise ValueError(f"order value {size * price} below the {market['minNotional']} minimum")
      return {"price": decimal_str(price), "size": decimal_str(size)}


  hype = {"exchange": "hyperliquid", "priceDecimals": 4, "baseDecimals": 2,
          "quoteDecimals": 2, "minNotional": "10"}
  print(prepare_order(Decimal("200"), Decimal("38.5274"), hype))
  # -> {'price': '38.527', 'size': '5.19'}

  extended = {"exchange": "extended", "priceDecimals": 2, "baseDecimals": 3, "quoteDecimals": 2,
              "stepSize": "0.001", "minSize": "0.001", "minNotional": "10"}
  print(prepare_order(Decimal("50"), Decimal("38.5274"), extended))
  # -> {'price': '38.52', 'size': '1.298'}
  ```

  ```typescript TypeScript theme={null}
  import Decimal from "decimal.js";

  Decimal.set({ precision: 40, toExpNeg: -9e15, toExpPos: 9e15 });

  interface Market {
    exchange: string;
    priceDecimals: number;
    baseDecimals: number;
    quoteDecimals: number;
    stepSize?: string; // Aster: data.stepSize; Extended: data.tradingConfig.minOrderSizeChange
    minSize?: string;  // Extended: data.tradingConfig.minOrderSize; Aster: LOT_SIZE.minQty
    minNotional: string;
  }

  function roundPrice(price: Decimal, m: Market): Decimal {
    if (m.exchange === "hyperliquid") {
      if (price.isInteger()) return price; // integer prices are always valid
      price = price.toDecimalPlaces(Math.max(6 - m.baseDecimals, 0), Decimal.ROUND_DOWN);
      return price.toSignificantDigits(5, Decimal.ROUND_DOWN); // cap at 5 significant figures
    }
    return price.toDecimalPlaces(m.priceDecimals, Decimal.ROUND_DOWN);
  }

  function roundSize(size: Decimal, m: Market): Decimal {
    let rounded: Decimal;
    if (m.stepSize !== undefined) { // Aster / Extended publish a lot step
      const step = new Decimal(m.stepSize);
      rounded = size.div(step).toDecimalPlaces(0, Decimal.ROUND_DOWN).times(step);
    } else { // Hyperliquid / Lighter: round to decimals
      rounded = size.toDecimalPlaces(m.baseDecimals, Decimal.ROUND_DOWN);
    }
    if (rounded.isZero() && !size.isZero()) throw new Error("size truncates to 0 — order is too small");
    return rounded;
  }

  function prepareOrder(notional: string, limitPrice: string, m: Market): { price: string; size: string } {
    const price = roundPrice(new Decimal(limitPrice), m);
    const size = roundSize(new Decimal(notional).toDecimalPlaces(m.quoteDecimals, Decimal.ROUND_DOWN).div(price), m);
    if (m.minSize !== undefined && size.lt(m.minSize)) throw new Error(`size ${size} below the ${m.minSize} minimum`);
    if (size.times(price).lt(m.minNotional)) throw new Error(`order value ${size.times(price)} below the ${m.minNotional} minimum`);
    return { price: price.toString(), size: size.toString() };
  }

  const hype: Market = { exchange: "hyperliquid", priceDecimals: 4, baseDecimals: 2, quoteDecimals: 2, minNotional: "10" };
  console.log(prepareOrder("200", "38.5274", hype));
  // -> { price: '38.527', size: '5.19' }

  const extended: Market = { exchange: "extended", priceDecimals: 2, baseDecimals: 3, quoteDecimals: 2, stepSize: "0.001", minSize: "0.001", minNotional: "10" };
  console.log(prepareOrder("50", "38.5274", extended));
  // -> { price: '38.52', size: '1.298' }
  ```

  ```go Go theme={null}
  package main

  import (
  	"fmt"

  	"github.com/shopspring/decimal"
  )

  type Market struct {
  	Exchange      string
  	PriceDecimals int32
  	BaseDecimals  int32
  	QuoteDecimals int32
  	StepSize      string // Aster: data.stepSize; Extended: data.tradingConfig.minOrderSizeChange ("" = none)
  	MinSize       string // Extended: data.tradingConfig.minOrderSize; Aster: LOT_SIZE.minQty ("" = none)
  	MinNotional   string
  }

  // truncate rounds value DOWN to `decimals` decimal places (handles negative decimals).
  func truncate(v decimal.Decimal, decimals int32) decimal.Decimal {
  	return v.Shift(decimals).Truncate(0).Shift(-decimals)
  }

  func roundPrice(price decimal.Decimal, m Market) decimal.Decimal {
  	if m.Exchange == "hyperliquid" {
  		if price.Equal(price.Truncate(0)) {
  			return price // integer prices are always valid
  		}
  		maxDec := int32(6) - m.BaseDecimals
  		if maxDec < 0 {
  			maxDec = 0
  		}
  		price = truncate(price, maxDec)
  		adjusted := price.NumDigits() - 1 + int(price.Exponent())
  		return truncate(price, int32(5-1-adjusted)) // cap at 5 significant figures
  	}
  	return truncate(price, m.PriceDecimals)
  }

  func roundSize(size decimal.Decimal, m Market) (decimal.Decimal, error) {
  	var rounded decimal.Decimal
  	if m.StepSize != "" { // Aster / Extended publish a lot step
  		step := decimal.RequireFromString(m.StepSize)
  		rounded = size.Div(step).Truncate(0).Mul(step)
  	} else { // Hyperliquid / Lighter: round to decimals
  		rounded = truncate(size, m.BaseDecimals)
  	}
  	if rounded.IsZero() && !size.IsZero() {
  		return rounded, fmt.Errorf("size truncates to 0 — order is too small")
  	}
  	return rounded, nil
  }

  func prepareOrder(notional, limitPrice string, m Market) (map[string]string, error) {
  	price := roundPrice(decimal.RequireFromString(limitPrice), m)
  	rawSize := truncate(decimal.RequireFromString(notional), m.QuoteDecimals).Div(price)
  	size, err := roundSize(rawSize, m)
  	if err != nil {
  		return nil, err
  	}
  	if m.MinSize != "" && size.LessThan(decimal.RequireFromString(m.MinSize)) {
  		return nil, fmt.Errorf("size %s below the %s minimum", size, m.MinSize)
  	}
  	if size.Mul(price).LessThan(decimal.RequireFromString(m.MinNotional)) {
  		return nil, fmt.Errorf("order value %s below the %s minimum", size.Mul(price), m.MinNotional)
  	}
  	return map[string]string{"price": price.String(), "size": size.String()}, nil
  }

  func main() {
  	hype := Market{Exchange: "hyperliquid", PriceDecimals: 4, BaseDecimals: 2, QuoteDecimals: 2, MinNotional: "10"}
  	order, _ := prepareOrder("200", "38.5274", hype)
  	fmt.Println(order) // map[price:38.527 size:5.19]

  	extended := Market{Exchange: "extended", PriceDecimals: 2, BaseDecimals: 3, QuoteDecimals: 2, StepSize: "0.001", MinSize: "0.001", MinNotional: "10"}
  	order, _ = prepareOrder("50", "38.5274", extended)
  	fmt.Println(order) // map[price:38.52 size:1.298]
  }
  ```
</CodeGroup>

Submit the prepared values:

```bash theme={null}
curl -X POST https://perps-api.vooi.io/exchange/orders \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "exchange": "hyperliquid",
    "asset":    "HYPE",
    "side":     "buy",
    "price":    "38.527",
    "size":     "5.19"
  }'
```

Build the optional `market` fields from the row's `data` per venue:

* **Aster** and **Binance** — `stepSize` and `minSize` from the `LOT_SIZE` filter, `minNotional` from the `MIN_NOTIONAL` filter (both under `data.filters`).
* **Extended** — `stepSize` = `data.tradingConfig.minOrderSizeChange`, `minSize` = `data.tradingConfig.minOrderSize`.
* **Hyperliquid** — omit `stepSize` and `minSize`; set `minNotional` to `"10"`.
* **Lighter** and **Robinhood** — omit `stepSize` and `minSize` (no minimum is published on the row); set `minNotional` to your blanket floor.

## When an order is still rejected

If a prepared order is rejected, parameter problems come back as structured `400`s with a per-field `errors` array; genuinely venue-side rejections (insufficient margin, would-not-reduce, and similar) come back as `503`, and how readable those are depends on the venue. See [Errors](/docs/errors) for the status codes, the response shapes, and which venues return the venue's own reason. As a rule, confirm fills through [order status or the real-time stream](/docs/trading-streams) rather than trusting the submit response alone.

## Next steps

<CardGroup cols={2}>
  <Card title="Orders" icon="file-signature" href="/docs/trading-orders">
    The full order request: types, brackets, batch, and cancel
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/docs/errors">
    Status codes and how venue rejections are surfaced
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.