# Hyperliquid — Orders

Order placement, types, tif, reduceOnly, native TP/SL, price and size rounding, minimums, exchange responses, cancellations, statuses, and rejections. SDK used in snippets: `@nktkas/hyperliquid` 0.27.x.

## TL;DR

1. **One request:** `exchange.order({ orders: [{ a, b, p, s, r, t }], grouping })`. `p` (price) and `s` (size) are passed as **strings**, already rounded to the grid. The SDK does not round anything itself. HL has no “market” order; use an IoC limit order at mid ± slippage.
2. **Price:** no more than 5 significant digits **and** no more than `6 − szDecimals` decimal places for perps. An integer price is always valid. **Size** is rounded down to the `10^-szDecimals` lot, with an epsilon before floor (`0.29*100 = 28.999999999999996`). Size calculation and submission must round with **the same function**.
3. **The minimum is $10 notional**, calculated from the order price after rounding: `Order must have minimum value of $10.`. Skip openings and partial reductions below the minimum in advance. **Never apply the minimum to a full reduceOnly close**: raise the size to the minimum and let the exchange clamp the fill to the position.
4. **reduceOnly** on HL clamps execution to the live position: the order cannot reverse the position, and resting reduceOnly orders are limited by the position. But reduceOnly does not protect against **excessive closing** by a partial order. Therefore, round partial reduceOnly only down; round up (ceil) only for a full close.
5. **Response:** `response.data.statuses[i]` has one of these forms: `{resting:{oid}}`, `{filled:{oid,totalSz,avgPx}}`, or `{error:"..."}`. **The SDK throws `ApiRequestError` if even one status is an error.** Retrieve statuses of neighboring batch orders that succeeded from `err.response`.
6. **Retries.** HTTP 429 is rejected before the matching engine, so a retry is safe. A 5xx, timeout, or disconnect means the **outcome is unknown**: do not repeat placement; reconcile against openOrders first. Broad retries are safe only for idempotent actions: cancel, `updateLeverage`, `agentEnableDexAbstraction`, and a **full** reduceOnly close.
7. **Order reads are scoped to a dex.** `frontendOpenOrders`/`openOrders` without `dex` do not show HIP-3 (`xyz:`) orders; make a separate request with `dex:'xyz'`. The main-dex response includes spot orders (`@85`, `PURR/USDC`). Limit orders return `triggerPx` as the truthy string `"0.0"`. Position TP/SL returns `sz` as `"0.0"`.
8. **Alo (post-only):** an order that would cross the order book is **rejected** (`badAloPxRejected`), not filled as taker. Pin the price one tick away from the best opposite quote.
9. **Native TP/SL:** `grouping:'positionTpsl'`, `s:'0'` (size tracks the entire position), `r:true`, with a mark-price trigger. The response contains the strings `'waitingForTrigger'`/`'resting'` **without an oid**.
10. **Cancellation:** the response is `'success'` or `{error}`. Errors containing `never placed / already canceled / filled` mean “the order is already gone.” Anything else means cancellation is **not confirmed**: do not place a replacement for that coin until reconciliation succeeds.

---

## 1. Order placement: payload

### 1.1 Order fields

| Field | Type | Meaning |
|---|---|---|
| `a` | number | asset index from `meta`. For the HIP-3 xyz dex, the index is offset: `110000 + idx`. Address assets by index everywhere, not by coin name (example at verification time: HYPE = 159 on main) |
| `b` | boolean | `true` = buy. Responses encode side with a letter: `'B'` = bid/buy, `'A'` = ask/sell |
| `p` | string | already formatted limit price (see §5). For a trigger order, this is the worst acceptable price after triggering |
| `s` | string | size as a string, with no more than `szDecimals` decimal places. `'0'` occurs only for position TP/SL |
| `r` | boolean | reduceOnly |
| `t` | object | `{ limit: { tif: 'Gtc' \| 'Ioc' \| 'Alo' } }` or `{ trigger: { isMarket, triggerPx, tpsl: 'tp' \| 'sl' } }` |
| `grouping` | string | `'na'` for ordinary orders, `'positionTpsl'` for a position TP/SL pair |
| `builder` | `{ b, f }` | optional builder fee |

### 1.2 Snippet: limit / IoC

```ts
// exchange is an ExchangeClient from @nktkas/hyperliquid 0.27.x (signed by the agent key)
const res = await exchange.order({
  orders: [{
    a: assetIndex,                 // number; xyz: 110000 + idx
    b: isBuy,                      // true = buy
    p: formatPx(px, szDecimals),   // STRING
    s: formatSz(sz, szDecimals),   // STRING
    r: reduceOnly,
    t: { limit: { tif: 'Gtc' } },  // 'Gtc' | 'Ioc' | 'Alo'
  }],
  grouping: 'na',
  // builder: { b: '0xBUILDER_ADDRESS', f: fee },  // optional
});
```

### 1.3 Before the first order in an asset

- **Leverage.** The first entry in an asset is `updateLeverage`, then `order`. On later entries, once leverage is set, `order` is enough. The first entry requires two exchange actions, not one (check the exchange-request weight formula in the limits section).
- `updateLeverage` is idempotent and can be retried on any transient error. **Always check the response**: a failed leverage update must not be swallowed. If leverage is not confirmed, block **only opening** orders; do not block closes.
- Set `isCross` per market. `isCross: true` is rejected for an isolated-only asset (HIP-3 equities are often isolated). A simple rule is `isCross = !onlyIsolated`, using the flag from meta.
- **HIP-3 through an agent wallet** requires `agentEnableDexAbstraction`. The error text `Abstraction transition not allowed` means the account has already transitioned and is not an error. Treat every other error as fatal for this order and do not submit it.
- **Preflight without trading.** A signed `updateLeverage` is a cheap way to verify that the agent key signs for the intended account or subaccount without placing an order.

---

## 2. Order types and time-in-force

### 2.1 tif

| tif | Behavior | When to use | Pitfalls |
|---|---|---|---|
| `Gtc` | rests in the book; the remainder waits | resting limits, reduceOnly TP | an order larger than the position without `r:true` crosses through zero and opens the opposite position |
| `Ioc` | fills immediately at the best prices within the limit; **the remainder is discarded** | “market” entry/exit, flatten | may fill partially. If it does not cross the book, it does not fill at all |
| `Alo` | post-only: maker only | passive maker orders | an order that would cross the book is **rejected** (`badAloPxRejected`) |

Observations:
- Not every order in `historicalOrders` has a `tif` field; do not require it while parsing.
- `badAloPxRejected` is normal and transient: simply place the order again on the next tick.
- If Alo nevertheless returns as `filled`, record it as an immediate fill and do not add it to the local book; the exchange considered the price crossing.

### 2.2 “Market” = IoC limit

```
buy:  limitPx = mid × (1 + slippagePct/100)
sell: limitPx = mid × (1 − slippagePct/100)
```

- IoC fills at the **best** prices in the order book. The limit is only a cap, not the execution price. A wide limit hurts only in the tail when the price actually moves away.
- IoC **does not fill exactly at mid** because it does not cross the spread. Shift it far enough toward execution to cross the spread.
- **Check the spread, not only depth.** If the spread is wider than the crossing allowance, IoC cancels silently even when the best level has enough size. This looks like a minimum-size failure. Increasing the crossing allowance is almost safe: the fill still occurs at the best available price.
- Slippage: use a **narrow entry** and a **wider reduceOnly exit**. A narrow cap on a reduceOnly exit may fail to cross the book during latency, a 429 storm, or on an illiquid xyz market, leaving a position open when it must close. An aggressive reduceOnly exit has no reversal downside.
- Mid source: `allMids`, separately for each dex. If mid is unavailable (`null`), skip entry and enqueue the close for retry.

### 2.3 Trigger orders (TP/SL)

- `t: { trigger: { isMarket: true, triggerPx: '<str>', tpsl: 'sl' | 'tp' } }`. `triggerPx` is the trigger level based on mark price; `p` is the worst acceptable price after triggering.
- In `frontendOpenOrders` responses they have `isTrigger: true`; position triggers also have `isPositionTpsl: true`. Match `orderType` with `/stop/i` for SL and `/take\s*profit/i` for TP. Exact `orderType` strings were not captured, except `'Limit'` for ordinary limits.
- See §4 for details.

---

## 3. reduceOnly

### 3.1 Semantics on HL

| Property | HL |
|---|---|
| Allowed for `Gtc`/`Alo` (resting) | yes |
| Allowed for `Ioc` | yes |
| Order larger than position | clamped to the live position, **not** rejected |
| Resting reduceOnly after position reaches zero | canceled automatically by the exchange with status `reduceOnlyCanceled` |
| Resting reduceOnly reversing a position | impossible: the fill is limited by the position |
| Protection against **excessive** closing by a partial order | **none**: a partial RO order fills its full size within the position |
| RO at zero position or toward the position | does not fill; the exact rejection text was not verified live (§11) |

Consequences:
- **A full reduceOnly close is idempotent.** Repeating it after an ambiguous response cannot reverse the position or open the opposite side, so it may be retried on 5xx and timeouts. It may also rely on a lagging snapshot: reduceOnly against an already absent position does nothing.
- **A partial reduceOnly is not idempotent.** Repeating a partial reduction or TP step after “success with timeout” reduces the position a second time.
- **Round a full close up (ceil)** and raise it to the dollar minimum (§6); the exchange clamps it to the position. Use floor only for a partial reduceOnly, and skip it when it cannot be represented on the lot grid.
- **Multiple reduceOnly TPs over a position are safe on HL.** Excess reduceOnly orders cannot open a position, so an IoC entry racing a reduceOnly fill cannot reverse the position.
- **An orphaned reduceOnly trigger SL is dangerous** if it survives until the **next** position in the same pair. The exchange cancels reduceOnly when flat, but explicitly cancel saved oids as a safety net (fire-and-forget; cancellation is idempotent).
- **Exiting with an ordinary limit (`r:false`) reverses the position** if the combined size of exit orders exceeds the position. The difference is invisible until the position is fully built.

### 3.2 Classify orders by flag, not side

- Determine the role of a resting order from the exchange's `reduceOnly` flag: `isTp = reduceOnly !== undefined ? reduceOnly : side === tpSide`. Side-based parsing mistakes an ordinary non-reduceOnly sell for a protective TP and counts it as coverage even though the exchange can use it to open a short.
- **Bot pause:** cancel every order with `reduceOnly !== true`, and treat an unknown value as opening (fail-safe). Canceling “by side” can leave ordinary non-reduceOnly sell orders larger than the position; their fills carry the position through zero into a short.

---

## 4. Native TP/SL (positionTpsl)

### 4.1 Placement

```ts
// A pair of stops for an existing position in one request
const exitIsBuy = positionSide === 'SHORT';   // EXIT side
const res = await exchange.order({
  orders: [
    { a: assetIndex, b: exitIsBuy, p: slWorstPxStr, s: '0', r: true,
      t: { trigger: { isMarket: true, triggerPx: slTriggerPxStr, tpsl: 'sl' } } },
    { a: assetIndex, b: exitIsBuy, p: tpWorstPxStr, s: '0', r: true,
      t: { trigger: { isMarket: true, triggerPx: tpTriggerPxStr, tpsl: 'tp' } } },
  ],
  grouping: 'positionTpsl',
});
```

- `s: '0'` means the size tracks the **entire** position. No size-based replacement is needed after an increase or partial close, but replace the levels when the average entry price changes.
- `r: true` is required. `b` is the exit side. Triggering uses **mark price**.
- Main advantage: the HL matching engine executes the stops even when the bot backend is down or throttled by 429.
- The payload format was confirmed against ccxt, the SDK, and HL documentation (2026-07-08).
- **Do not attach builder fee to protective orders** (TP/SL or final reduceOnly orders). If the user revoked builder approval but the cache does not know yet, the protective order is rejected.

### 4.2 Response without oid

- With `grouping:'positionTpsl'`, `statuses` entries arrive as the **strings** `'waitingForTrigger'` / `'resting'`, **without an oid** (verified live 2026-07-09). Support the object form `{resting}/{filled}/{error}` as a fallback.
- To obtain the oid, read `frontendOpenOrders` after placement and match on `coin`, `isTrigger`, `reduceOnly`, `orderType` (`/stop/i` → SL, `/take\s*profit/i` → TP), and `triggerPx` with tolerance `|a − b| <= max(target × 1e-5, 1e-9)` because HL may return a normalized price representation. Registration is not immediate: make 2 attempts 500 ms apart.

### 4.3 Lifecycle

| Event | Status |
|---|---|
| One leg triggered | the exchange cancels the other: `siblingFilledCanceled` |
| Position closed another way (IoC, liquidation) | reduceOnly triggers are canceled: `reduceOnlyCanceled` |
| Trigger fired and filled | `filled` |
| Trigger fired, order in flight | `triggered`: recheck |

- Check whether a stop fired through `orderStatus` using the saved oid, not from the position disappearing; another path may have closed it.
- Compute the **average entry price after increasing a position** locally when moving stops, without REST: `(preSz × preEntry + fillSz × fillAvgPx) / (preSz + fillSz)`. This avoids racing state that has not updated yet.

---

## 5. Price and size rounding

### 5.1 HL rules

| Item | Rule |
|---|---|
| Price (perp) | ≤ 5 significant digits **and** ≤ `6 − szDecimals` decimal places; an integer is always valid |
| Price (spot) | ≤ 5 significant digits and ≤ `8 − szDecimals` decimal places |
| Size | ≤ `szDecimals` decimal places, lot `10^-szDecimals` |
| Violation | `Order has invalid price` for price / rejection for size |

Examples (reference test cases):

| Input | szDecimals | Result | Why |
|---|---|---|---|
| `83.20512` (HYPE) | 2 | `'83.205'` | 5 significant digits |
| `79555.55` (BTC) | 5 | `'79556'` | 5 significant digits remove the entire fractional part |
| `1.4220512` (XRP) | 0 | `'1.4221'` | the ceiling is 5 significant digits, not `1.42205` |
| `1.3521` | 0 | `'1.3521'` | already on the grid, unchanged |
| EIGEN ≈ `0.26523` | ≥2 | `0.2652` | with `szDecimals ≥ 2`, only 4 decimal places are allowed; checking significant digits alone caused rejections |

### 5.2 The price tick floats

```
sigTick = 10^(floor(log10(px)) − 4)       // fifth significant digit
decTick = 10^−(6 − szDecimals)
tick    = max(sigTick, decTick)
// variant accounting for “integers are always valid”: tick = max(decTick, min(1, sigTick))  → at px ≥ 1e5 step 1
```

- HYPE with `szDecimals=2` (`decTick = 0.0001`) at price 82.716 still moves in **0.001** steps (0.12 bp), because `82.7165` already has 6 significant digits. BTC around 77 500–79 556 moves in $1 steps.
- **When price crosses a power of 10, the tick jumps 10×.** Prices 1000.2 and 950.02 have ticks of 0.1 and 0.01. Any parameter expressed “in ticks” is not scale-invariant and must be capped as a fraction of price.
- Calculate the tick from the current price; do not store it as a constant.
- **Do not derive the tick from the price string length**: formatters remove trailing zeros, making the tolerance band several times wider at a round price.

### 5.3 Snippets

```ts
// Perp price: 5 significant digits, then ≤ (6 − szDecimals) decimal places. String() removes trailing zeros.
export function formatPx(px: number, szDecimals: number): string {
  if (Math.abs(px) >= 1e4) return String(Math.round(px)); // integers are always valid; otherwise 123456.7 → 123460 ($10 step)
  const maxDec = Math.max(0, 6 - szDecimals);
  let p = Number(px.toPrecision(5));
  p = Number(p.toFixed(maxDec));
  return String(p);
}

// Size: floor/ceil to the lot with an epsilon against binary arithmetic
export function floorSz(sz: number, szDecimals: number): number {
  const f = 10 ** szDecimals;
  return Math.floor(sz * f + 1e-9) / f;
}
export function ceilSz(sz: number, szDecimals: number): number {
  const f = 10 ** szDecimals;
  return Math.ceil(sz * f - 1e-9) / f;
}
export const formatSz = (sz: number, d: number) => floorSz(sz, d).toFixed(d);

// Size accounting for the minimum at the ORDER price
export function sizeFor(usd: number, px: number, szDecimals: number, minNotionalUsd = 0): number {
  if (!(usd > 0) || !(px > 0)) return 0;
  let sz = floorSz(usd / px, szDecimals);
  if (minNotionalUsd > 0 && sz * px < minNotionalUsd) sz = ceilSz((minNotionalUsd * 1.002) / px, szDecimals);
  return sz;
}
```

Another epsilon variant seen in practice is `Math.floor((sz + 1e-12) * f) / f`, followed by `.toFixed(szDecimals)`. It works, but an absolute epsilon before multiplication is weaker for very large sizes than `sz*f + 1e-9`. The key is to use **the same epsilon and the same function** in every layer. A variant without epsilon (`Math.floor(value*factor)`) is vulnerable to floating-point error.

Price through `toPrecision`/`Math.round` rounds to nearest, so the limit may move by a fraction of a tick in either direction. This is not critical for slippage limits. Post-only needs directional pinning: bid one tick below `bestAsk`, ask one tick above `bestBid`.

### 5.4 Size-rounding direction

| Action | Rounding | Bump to minimum |
|---|---|---|
| Open / increase | floor | **never** |
| Partial reduceOnly (TP step, partial reduction) | floor | **never**. Skip if it cannot be represented |
| Full reduceOnly close | **ceil** | **yes**, to the required number of lots |

- **Floor on a full close leaves dust.** Example with hypothetical numbers: position 0.5000 at lot 0.0001, but floor produces close size 0.4999, leaving one lot below $10 that cannot be closed separately. With ceil and a bump to the minimum (§6.3), the size is not smaller than the position and reduceOnly clamps it to the position.
- **Ceil on a partial reduceOnly** closes too much: reducing by 0.4 at `szDecimals=0` submits size 1 and closes the entire position of 1, although only part should be reduced.
- **Bumping an opening by one lot is not pennies.** One xyz:STRC lot (`szDecimals=1`, price ≈ $87) is worth ≈ $8.7, below the minimum: a $10 opening rises to two lots, ≈ $17.4, almost twice the target. Rule: do not place an opening order that cannot be represented on the lot grid without materially distorting size.
- Size-decision test cases ($10 minimum): partial RO `0.2 @ szDec=0` → skip; partial RO `0.001 @ $5000, szDec=3` ($5) → skip (bumping to 0.002 would reduce twice as much as intended); partial RO `0.29 @ $35, szDec=2` ($10.15) → submit; full close `0.2 @ szDec=0` → ceil to 1 lot; full close `0.001 @ $3000, szDec=3` → 0.004 ($12); open `0.2 @ $1000, szDec=0` → skip (one lot overshoots 5×); open `0.0019 @ $5500, szDec=3` → floor 0.001 = $5.5 → skip; open `5 @ $1000, szDec=2` → unchanged.

### 5.5 Comparing price with a live order

- Compare a **quantized string with a quantized string** (`formatPx(target) === limitPxString`). A raw number never matches the exchange string: 12.3456 is submitted as 12.346 and returned as 12.346, so “an order already rests at the desired price” otherwise always evaluates false.

---

## 6. Minimum order size

### 6.1 Facts

- **$10 notional** on main perp and HIP-3 xyz: `px × sz ≥ 10`, **at the order price** rather than mid and **after size rounding**. Error text: `Order must have minimum value of $10.` (verified against official documentation 2026-09-13). Third-party sources call such xyz orders dust.
- HL has no separate minimum in base units (`minSz`), only `szDecimals` and the dollar minimum.

### 6.2 How to check

```ts
const szDec = meta?.szDecimals ?? 4;
const flooredSz = Math.floor(size * 10 ** szDec + 1e-9) / 10 ** szDec;
if (!isFullReduceOnlyClose && flooredSz * orderPx < 10) skip('order_below_min_after_rounding');
```

- Checking **before** rounding admits borderline orders: an increment just above $10 becomes less than $10 after floor and is rejected. These rejections arrive in batches, wasting exchange-request weight and polluting logs.
- A passive order calculated as “exactly $10” at mid falls below the minimum after flooring size and pricing below mid. Calculate size from the order price and include a buffer.
- If downsizing to order-book depth (§8.1) leaves < $10, skip with a reason such as `insufficient_book_depth`.
- An IoC-action minimum may be a separate parameter, but it must be **no lower than the real exchange minimum**, or every market action becomes a rejection. Sequence: measure the minimum → configure with a buffer.

### 6.3 A full close is not limited by the minimum

```ts
// Full reduceOnly close: ceil to the lot and bump to the required number of lots
const lot = 10 ** -szDecimals;
let placeable = ceilSz(positionAbs, szDecimals);
const lotsForMinimum = Math.ceil(minNotionalUsd / (px * lot));
placeable = Math.max(placeable, lotsForMinimum * lot);
if (placeable * px < minNotionalUsd) placeable += lot;   // protection against 9.999999999999998
placeable = Number(placeable.toFixed(szDecimals));
```

- One lot is not always enough: 0.001 ETH @ $3000 (`szDecimals=3`) requires 4 lots, `0.004` = $12. Raising by exactly one lot (0.002 = $6) causes a permanent rejection loop.
- Applying the minimum to a full close leaves a tiny position **forever**: the gate silently skips the < $10 remainder while accounting considers the position closed. Invariant: a full reduceOnly close is **never** checked against either the dollar minimum or a custom IoC minimum. Partial reductions and openings are checked.
- If an exit has no reference price (`refPx <= 0`), defer it to the next cycle; otherwise the notional check (`sz × 0 < min`) silently discards a partial reduction.
- Do not repeatedly attack existing dust in a loop or errors will spam. If the exchange returns a minimum error (`/minimum value|min.*value/i`), mark the remainder as dust, exit the loop, and request human action to close it through the UI.

---

## 7. Order response and SDK errors

### 7.1 Successful response shape

```ts
{ status: 'ok', response: { data: { statuses: [
  { resting: { oid: 42 } },                                   // resting in the book
  { filled:  { oid: 43, totalSz: '1.2', avgPx: '99' } },      // filled (strings!)
  { error:   'Order must have minimum value of $10.' },        // rejected
] } } }
```

- `statuses[i]` corresponds to the i-th order in the batch.
- `totalSz` and `avgPx` are strings. `oid` is a number; `Number` is safe while it remains below `2^53`.
- `positionTpsl` entries are strings (§4.2).

### 7.2 The SDK throws on any order-level error

`@nktkas/hyperliquid` validates the response with assertSuccessResponse and throws **`ApiRequestError`** if even one status is `error`. For real rejections, the branch `if (statuses[0].error)` after `await` is **unreachable** because the rejection arrives as an exception. The response body is stored in the error:
- `err.response` = `{ status: 'ok', response: {...} }` for a partial batch rejection: neighboring orders **may have succeeded**, so parse each entry;
- `err.response` = `{ status: 'err', response: 'text' }` when the entire action is rejected.

Transport errors arrive as `HttpRequestError` with the HTTP code in `err.response.status`.

```ts
function apiErrorBody(e: any): { status: string; response: unknown } | null {
  if (!e || e.name !== 'ApiRequestError' || !e.response || typeof e.response.status !== 'string') return null;
  return { status: e.response.status, response: e.response.response };
}

// HttpRequestError: 429 and 4xx except 408 have a known outcome; 5xx/408/timeout/disconnect are unknown
function transportOutcome(e: any) {
  const status = e?.name === 'HttpRequestError' && typeof e.response?.status === 'number' ? e.response.status : 0;
  const msg = String(e?.message ?? e).slice(0, 200);
  if (status === 429) return { batchError: `HTTP 429: ${msg}`, outcomeUnknown: false, rateLimited: true };
  if (status >= 400 && status < 500 && status !== 408) return { batchError: `HTTP ${status}: ${msg}`, outcomeUnknown: false };
  return { batchError: `transport: ${msg}`, outcomeUnknown: true };
}

async function place(assetIndex: number, orders: PlaceSpec[]) {
  try {
    const res = await exchange.order({
      orders: orders.map((o) => ({ a: assetIndex, b: o.side === 'B', p: o.pxStr, s: o.szStr,
        r: o.reduceOnly === true, t: { limit: { tif: o.tif ?? 'Gtc' } } })),
      grouping: 'na',
    });
    return { statuses: parseOrderStatuses(res), outcomeUnknown: false };
  } catch (e) {
    const body = apiErrorBody(e);
    if (body?.status === 'ok') return { statuses: parseOrderStatuses(body), outcomeUnknown: false }; // partial rejection
    if (body) return { statuses: [], batchError: String(body.response).slice(0, 200), outcomeUnknown: false };
    return { statuses: [], ...transportOutcome(e) };
  }
}

function parseOrderStatuses(raw: any) {
  const st = raw?.response?.data?.statuses;
  if (!Array.isArray(st)) return [];
  return st.map((x: any) =>
    x?.resting ? { kind: 'resting', oid: Number(x.resting.oid) } :
    x?.filled  ? { kind: 'filled', oid: Number(x.filled.oid), totalSz: Number(x.filled.totalSz), avgPx: Number(x.filled.avgPx) } :
                 { kind: 'error', error: String(x?.error ?? JSON.stringify(x)).slice(0, 200) });
}

function parseCancelStatuses(raw: any): Array<'success' | string> {
  const st = raw?.response?.data?.statuses;
  if (!Array.isArray(st)) return [];
  return st.map((s: any) => (s === 'success' ? 'success' : String(s?.error ?? JSON.stringify(s)).slice(0, 200)));
}
```

### 7.3 Fail-closed parsing

Success requires **explicit** confirmation for each entry:
- `status !== 'ok'` → REJECTED;
- missing `statuses` or an empty array → **not confirmed** (`{status:'ok', statuses:[]}` is a trap);
- unknown entry shape (`{futureShape:true}`) → not confirmed;
- `filled` with nonnumeric `totalSz` (`'Infinity'`), invalid `avgPx`/`oid`, or `totalSz > requestedSz` with tolerance 1e-9 → invalid fill;
- `resting` with an invalid oid → not confirmed.

“Not confirmed” **does not mean “did not reach the exchange.”** Treat an entry as definitely not submitted only when there are no results at all because the batch was deferred before submission, or every omission is a local `SKIPPED` before submission. REJECTED/RESTING with an unusual shape or transport ambiguity does not prove this. Then reconcile the book and do not advance fill accounting.

### 7.4 Partial IoC fill

- An IoC `filled` result may be **partial**. Completeness check: `szTick = 1 / 10**szDecimals; fullyFilled = requestedSz <= 0 || filledSz >= requestedSz − szTick`. One-tick tolerance is needed because of rounding.
- After a partial close, do not mark the position closed: account for PnL on the filled part and enqueue the remainder for a reduceOnly follow-up.
- **Hidden remainder.** reduceOnly clamps a fill to the **actual** position. If a ceil-sized order from a **stale** snapshot filled completely, the actual position may have been larger. Reread `clearinghouseState` through REST and count a remainder only when `freshSize > filledSz + szTick`. With an exact close, the actual position equals `filledSz`, so there is no false positive.

---

## 8. Using IoC

### 8.1 Walk-the-book before entry

```ts
// Amount available within the limit price (inclusive)
function depthWithinLimit(levels: {px: number; sz: number}[], isBuy: boolean, limitPx: number) {
  if (!Number.isFinite(limitPx)) return { size: 0, notional: 0 };
  let size = 0, notional = 0;
  for (const l of levels) {                     // buy uses asks, sell uses bids from l2Book
    if (!(l.px > 0) || !(l.sz > 0)) continue;   // skip NaN/0/negative values
    if (isBuy ? l.px > limitPx : l.px < limitPx) continue; // beyond limit: skip, do not break
    size += l.sz; notional += l.sz * l.px;
  }
  return { size, notional };
}
// asks [100×1, 101×2, 102×3, 110×50], limit 101 → size 3, notional 100·1 + 101·2
// limit 99.5 → {0,0}; limit 200 → size 56
```

- Use only for **entry** (open/increase). If depth is insufficient, reduce size to `floor(available × 0.95)` at `szDecimals`, then repeat minimum checks. Log the actual partial entry explicitly.
- **Never limit closing by order-book depth**: exit is mandatory at any depth.
- This matters especially for HIP-3 equities and illiquid coins, where a full-size IoC consumes several levels.

### 8.2 Flatten (emergency close) with increasing slippage

```ts
// loop until the snapshot shows |sz| < 10^-szDecimals or until the deadline
const mid = wsMidFresh ?? restL2BookMid ?? entryPx;
const slip = Math.min(0.05, (baseSlippagePct / 100) * (1 + 0.5 * Math.min(attempt, 8)));
const px = isLong ? mid * (1 - slip) : mid * (1 + slip);
await place(assetIndex, [{ side: isLong ? 'A' : 'B', pxStr: formatPx(px, szDec),
  szStr: formatSz(Math.abs(sz), szDec), tif: 'Ioc', reduceOnly: true }]);
// pause 1200 ms ×1.5 up to 10 000 ms; 5 identical known errors in a row → stop and alert; minimum error → dust
```

An IoC at a stale price has nothing to match, so repeating the same order is pointless; increase slippage on every attempt (for example, base 0.5% from mid). Use WS mid only when the book is newer than the freshness threshold.

### 8.3 Reliable close (reconciler)

A close is not complete merely because it was submitted. If the result is `null` (account, mid, or meta), IoC is REJECTED, submission fails, or the fill is partial, enqueue the position. Periodically finish it with reduceOnly through **the same** close path until a read shows flat. Bound attempts and elapsed time, then raise a loud alert (for example, a delisted coin whose meta cannot be found). Read `clearinghouseState` before closing; no position is a safe no-op.

---

## 9. Cancellation, modify, and cloid

### 9.1 Cancel by oid

```ts
const res = await exchange.cancel({ cancels: oids.map((o) => ({ a: assetIndex, o })) });
// { status:'ok', response:{ data:{ statuses: ['success' | { error: string }] } } }
```

- Use a **numeric asset index**, not a coin name. Accept `oid` only when it is a finite number > 0.
- **Fail-closed:** only `statuses[i] === 'success'` is confirmed. `status !== 'ok'`, empty statuses, `{error}`, or an unexpected shape means “not confirmed.” Treating everything except an explicit error as canceled makes `{status:'err'}` and `{status:'ok', statuses:[]}` look successful, places a replacement over a live order, and submits the order **twice**.
- An error matching `/never placed|already canceled|already cancelled|filled/i` (full string: `Order was never placed, already canceled, or filled.`) means the order is absent from the book. Forget it locally instead of canceling forever. But **the position may have moved** because the order may have filled, making the snapshot stale.
- Any other cancel error means the order may still rest. Keep the record until reconciliation and do not replace that side and coin.
- Cancellation is idempotent and may be repeated (for example, 2 attempts with retry on transient errors). Handle a partial cancel-batch rejection like order placement through `err.response` with `status:'ok'`.
- **`success` does not mean “nothing filled”:** the order may have partially filled immediately before cancellation. This is harmless for reduceOnly orders on HL because of the position cap, but the position may have changed.
- Sequential throttled cancellations take seconds, during which active-market orders may fill. Account for this in calculations after cancellation.

### 9.2 When cancellation is not confirmed

The order most likely **already filled**, and the position snapshot is stale:
1. hold opening orders (not reduceOnly), or the order may fill twice;
2. hold **partial** reduceOnly orders (partial reduction, TP): size was calculated from a stale position and is recalculated for free on the next tick;
3. always submit a **full** reduceOnly close: HL clamps it to the live position;
4. restore missing TPs from a **trusted** fresh read, but do not submit an IoC calculated before cancellation; recalculate it from the fresh read.

### 9.3 Tick ordering

- **All cancellations first, then placements.** Cancellations release margin; otherwise placement may be rejected for margin even though enough would be available.
- If any cancellation returns a batch error or is unconfirmed, skip placements on that tick and set “reconciliation required.” Place nothing on a side whose order was not confirmed canceled, even if other cancellations succeeded.
- “Cancel everything and verify”: one round is cancel every known oid → wait 400 ms → `openOrders(coin)`. Empty means done. Otherwise adopt remaining orders into the local book and repeat. Backoff 1000 ms ×2 up to 30 000 ms with a deadline.
- **Clean start:** at the end of preflight, read `openOrders` for the coin, adopt orders left by a crashed process, and cancel them with confirmation. If this fails, do not start. Restart recovery is based on exchange data, not local state.

### 9.4 modify / batchModify

`batchModify` does not save budget: modify consumes the same address limit as placement. A simple alternative is replace = cancel + new order. Modify semantics, including whether oid and queue priority are preserved, are not verified.

### 9.5 cloid

- `frontendOpenOrders` returns `cloid` (`string | null`).
- **Without cloid, the bot cannot distinguish its orders from manual ones:** a bot that cancels every coin order absent from its list also cancels manual orders. Do not trade manually on such an account; use a separate account or subaccount.
- **Do not enable cloid tagging in a running bot:** existing orders without cloid stop being recognized and the bot places duplicates. A migration must first adopt existing orders. Design cloid in from day one.
- `cancelByCloid` is not verified.

---

## 10. Retries and unknown outcomes

### 10.1 Response classification

| Response | Reached matching engine? | Outcome | Reaction |
|---|---|---|---|
| HTTP 429 / `Too Many Requests` / `rate limit` | no, rejected by limiter | known: not executed | retry is safe **even for an opening order**; backoff (for example, 10 s) |
| HTTP 4xx except 408 | no | known | do not repeat unchanged; no reconciliation needed |
| `ApiRequestError` status `err` / `error` entry | processed | known: rejected | see §11 |
| HTTP 5xx, 408, timeout, disconnect | **unknown** | **unknown** | do **not** repeat placement; forbid new placements until reconciliation |
| Text containing `and retry` (`… please wait and retry`) | — | transient | retry according to action policy |

A common mistake is treating 4xx and 429 as unknown outcomes. That runs `openOrders` reconciliation on every tick and wastes weight.

### 10.2 Policy by action

| Action | Retry on 429 | Retry on 5xx / timeout / network |
|---|---|---|
| info reads | yes | yes (idempotent) |
| cancel | yes | yes |
| `updateLeverage`, `agentEnableDexAbstraction` | yes | yes |
| **Full** reduceOnly close | yes | yes: repetition is clamped to the remaining position, producing the same final state |
| Partial reduceOnly (partial reduction, TP step) | yes | **no**: repetition after “success with timeout” reduces twice |
| Open / increase | yes | **no**: repetition doubles entry |
| Transfers (`usdSend`, etc.) | yes | **no**: risk of double transfer |

Code rule: `idempotent = reduceOnly && fullClose`. `idempotent = reduceOnly` for any RO is wrong because partial RO is not idempotent. **Never** make an open/increase retryable on transient errors. If HL returns 500/timeout for an opening order, record ERROR and continue; the next cycle reconciles with the exchange.

### 10.3 Reconcile instead of retrying

- Do not blindly retry placement errors. Reconciliation reads the live book (`openOrders`/`frontendOpenOrders`), compares it with desired orders, and places missing orders only afterward.
- Placement is irreversible; cancellation is safe. Any doubt (unconfirmed cancellation, unknown outcome, failed reconciliation, unavailable WS) **forbids new placements** until the next successful exchange reconciliation, but **never forbids cancellations**.
- If one placement in a batch throws, still execute the remaining actions, especially reduceOnly closes.
- Do not immediately repeat a **persistent rejection** (minimum, margin, leverage): use backoff such as 30 s, or an infinite rejection loop burns the address limit. Replace a **transient** rejection (`/immediately|post only|alo/i`) on the next tick.

---

## 11. Error strings and rejections

| String / status | Where seen | Meaning | Action |
|---|---|---|---|
| `Order must have minimum value of $10.` | `statuses[i].error` | `px × sz < $10` | check in advance after rounding; do not immediately repeat (backoff); send dust to a human |
| `Order has invalid price` | `statuses[i].error` | violates 5-significant-digit / `6 − szDecimals` rule | `formatPx` |
| `Insufficient margin` | `statuses[i].error` | insufficient margin | not transient: backoff; cancel before placing; total resting-order margin ≤ available margin |
| `perpMarginRejected` | status in `historicalOrders` / `orderUpdates` | rejected for margin | keep total resting-order margin below available margin |
| `badAloPxRejected` | `orderUpdates` status and placement error | Alo crossed the book | transient: place again; pin by one tick |
| Alo crossing rejection; exact text not captured | mainnet `statuses[i].error`, partially verified 2026-09-14 | post-only order crossed the book | match `/immediately\|post only\|alo/i`; log raw string |
| `Order was never placed, already canceled, or filled.` | cancel error | order absent | cancellation success; position may have changed |
| `Abstraction transition not allowed` | `agentEnableDexAbstraction` | already enabled | not an error |
| `… please wait and retry` | various | transient | retry by policy |
| HTTP 429 | transport | limiter | retry is safe |

The table records strings and statuses from live responses or documentation. Exact reduceOnly and non-crossing IoC rejection strings were not verified live; capture the raw response instead of assuming a fixture's wording is an exchange contract. Any status ending in `Rejected` is a terminal rejection.

- **Alo rejection on mainnet is partially verified (2026-09-14).** “Crossed the book” rejections arrive in `statuses[i].error` and match `/immediately|post only|alo/i`. The rejection is transient, and rejected placement still consumes request limit. The full exact text was not captured. Log the raw string to establish its exact form.

---

## 12. Order statuses

### 12.1 `orderStatus` (info, weight 2)

```ts
const r = await info.orderStatus({ user: '0xYOUR_ADDRESS', oid });
// { status: 'order', order: { order: {...}, status: 'filled' | 'open' | 'canceled' | 'triggered'
//     | 'siblingFilledCanceled' | 'reduceOnlyCanceled' | ..., statusTimestamp } }
// or { status: 'unknownOid' }
```

In some SDK versions the method may be untyped; call it through `(info as any)`.

| Status | Interpretation |
|---|---|
| `open` | resting; if there is no position, the snapshot probably lags |
| `filled` | filled (for trigger: fired and closed) |
| `triggered` | fired, order in flight; recheck |
| `canceled`, `siblingFilledCanceled`, `reduceOnlyCanceled` | provably did not execute as a stop |
| `unknownOid` | oid aged beyond HL retention; **does not prove** it did not fill. Only heavy `userFills` gives the exact fill |
| transient error (`null`) | change nothing; recheck next tick |

### 12.2 Terminal statuses (`orderUpdates` / historicalOrders)

`filled`, `canceled`, `rejected`, `marginCanceled`, `vaultWithdrawalCanceled`, `openInterestCapCanceled`, `selfTradeCanceled`, `reduceOnlyCanceled`, `siblingFilledCanceled`, `delistedCanceled`, `liquidatedCanceled`, `scheduledCancel`, and **any status ending in `…Rejected`**.

`open`: the order is live; `sz` in an `orderUpdates` frame is the **current remainder**.

### 12.3 WS before REST

- A terminal status or WS fill may arrive **before** the placement response. Keep tombstones and apply them when the response arrives, including for orders adopted from WS.
- Remove a local record only when absent from **two** consecutive `openOrders` reads. Do not resurrect an order you canceled yourself.

---

## 13. Reading open orders

### 13.1 openOrders vs frontendOpenOrders

| | `openOrders` | `frontendOpenOrders` |
|---|---|---|
| Weight | 20 (see “Open questions”) | 20 (heavy) |
| Main fields | `coin`, `side`, `limitPx`, `sz`, `oid`, `timestamp` | same + `orderType`, `reduceOnly`, `isTrigger`, `isPositionTpsl`, `triggerPx`, `tif`, `cloid` |
| `dex` parameter | works (`dex:'xyz'`) | works |
| When to use | coin/side/px/sz/oid are enough | need `reduceOnly`, trigger flags, or `cloid` |

Without `reduceOnly`, code distinguishes orders only by side and price and mistakes an ordinary sell for a protective TP. The “never hold TP larger than the position” limit then does not work.

### 13.2 Fields and formats

```ts
interface FrontendOpenOrder {
  coin: string;            // 'BTC' | 'xyz:AMD' (HIP-3) | '@85' or 'PURR/USDC' (spot!)
  side: 'B' | 'A';         // B = bid/buy, A = ask/sell; any other value is invalid
  limitPx: string;         // string → Number(); retain the original string for comparison
  sz: string;              // string; position TP/SL uses "0.0"
  oid: number;             // safe integer ≥ 0; duplicate oid within snapshot/across dexes is an error
  timestamp: number;       // ms, placement time (order age)
  orderType: string;       // 'Limit' for ordinary orders
  reduceOnly: boolean;
  isTrigger: boolean;
  isPositionTpsl: boolean;
  triggerPx: string;       // "0.0" for limits is truthy in JS!
  tif?: string;
  cloid?: string | null;
}
```

### 13.3 Reading pitfalls

- **HIP-3 orders are visible only in a request with `dex`** (verified 2026-07-09). `{type:'frontendOpenOrders', user}` without `dex` returns main-dex only. An account whose resting orders are all on xyz shows **exactly 0** orders in the main response. Concatenate all dexes (`['', 'xyz']`) for the complete picture; omit `dex` for main.
- **Spot is included in the main-dex response.** `coin` may be `@85` (spot-pair index, PURR/USDC) or `PURR/USDC`. A perp bot filters with `if (coin.includes('/') || coin.startsWith('@')) continue;`; otherwise, “cancel everything absent from config” deletes unrelated spot orders.
- **`triggerPx: "0.0"` is truthy.** Detect a trigger with `o.isTrigger === true || Number(o.triggerPx) > 0`, not `if (o.triggerPx)`.
- **Position TP/SL carries `sz:"0.0"`.** Strict `px>0 && sz>0` validation for **every** entry before relevance filtering invalidates the entire snapshot. Apply strict validation only to managed orders (not spot, trigger, or positionTpsl); `isFinite && ≥ 0` is enough for the rest.
- **The “account has no orders” check** must include triggers and TP/SL. Managed orders and **all** open orders are different lists; filtered-out triggers remain exposure when the bot stops.
- **Ordinary resting limit order** (not reduceOnly, not a trigger, not TP/SL, and not spot): `!reduceOnly && !isPositionTpsl && !isTrigger && orderType.toLowerCase() === 'limit' && !coin.startsWith('@') && (side === 'A' || side === 'B')`.
- **An incomplete list is indistinguishable from an empty one.** Make irreversible decisions from an “empty” list only after confirming every dex was read successfully.
- **An untrusted read does not mean “no positions/orders.”** Skip the entire tick on read failure: reconciliation without a trusted list of your orders creates duplicates, while an empty snapshot reports position 0 for every coin and duplicates openings. A non-array response is degraded state.

### 13.4 Consistent position and order snapshot

- Orders and positions come from different endpoints. **Do not use `Promise.all`** for terminal decisions. Read in this order: `clearinghouseState` → `frontendOpenOrders` → `clearinghouseState`. The snapshot is stable only if the per-coin `szi` maps before and after are bit-for-bit identical. Otherwise, a fill between reads can produce “position before the fill, order already gone,” causing a duplicate re-entry or partial reduction.
- **A stable snapshot may lag behind a just-submitted order.** After an IoC, accept a snapshot only if `|position − expected| <= 0.5 × lot + 1e-12`, where `expected` = position before the order + confirmed `fillSize` (accounting for side and reduceOnly). Otherwise, reread with backoff (for example, `[0,0,0,250,500,1000,2000,4000,5000,5000]` ms, ≈18 s). Without a trusted view, do not submit either IoC or TP against stale state.
- **Terminal close:** first require causal evidence from the response (`FILLED` and expected position after the fill = 0); only then may a “flat” snapshot authorize cancellation of protective reduceOnly orders. REJECTED, throw, RESTING, SKIPPED, or a partial fill means the TPs remain. Before closing, recalculate size from a fresh snapshot; if the position sign has changed, stop and recalculate from a fresh read.
- **Position-appearance lag.** HL usually updates state in less than 500 ms. However, do not treat a record for a position opened less than ~10 s ago as out of sync: the position may not yet appear in state, and a bot that deletes the record will open it again and double it.

### 13.5 Load and limits

- A full snapshot for two dexes (main + xyz): 2× `frontendOpenOrders` + 2× `allMids` + 2× `clearinghouseState` = 6 info requests.
- **Open-order limit per address:** 1000, +1 for each $5M of trading volume, up to 5000.
- **Margin for resting orders** is calculated using leverage, rather than from notional value.

---

## 14. Submission speed and order

- **Submitting orders one at a time through your own throttler** takes seconds for a batch. This is throttler time, not raw HL latency.
- Therefore, if protective reduceOnly TP is submitted **after** all other orders, the position remains without resting protection throughout that time. Submit protection earlier: IoC → Gtc reduceOnly (protection) → the rest.
- HL orders are convenient to log one per line, making measurements easy to grep: `<coin> <B|A> <sz>@<px> <tif> [RO] -> <FILLED|RESTING|REJECTED> (<error>)`.

---

## Pitfalls

| What breaks | Why | Correct approach |
|---|---|---|
| The branch `statuses[0].error` never triggers | SDK throws `ApiRequestError` on any error status | parse `err.response` element-wise when `status:'ok'` |
| Duplicate orders after timeout | request reached, response lost, blind retry | do not retry order placement for 5xx/timeout; book reconciliation |
| Order placed twice | `{status:'ok', statuses:[]}` or `{status:'err'}` read as successful cancelation | fail-closed: only `'success'`; otherwise no replacement until reconciliation |
| Order at the correct price is considered “wrong” and replaced (burning write budget) | raw number compared with exchange string; tolerance derived from string length | compare quantized strings; calculate the tick from the price |
| TP-step 0.29 goes as 0.28 and is missed, position without take profit | `0.29*100 = 28.999999999999996`, different epsilon in code places | one quantization function with one epsilon |
| Dust position forever | floor on full close; gate $10 on full close | ceil + bump to minimum lot; full RO close without gate |
| Partial reduction closed entire position | ceil/bump partial reduceOnly (0.4 → 1 if `szDecimals=0`) | partial RO only floor, otherwise skip |
| Oversized opening almost doubles | bump opening to one lot on coarse grid | never raise opening above lot or minimum |
| Batches of minimum-value rejections | $10 checked before floor rounding | check notional after rounding at the order price |
| Rejected price for coins < $1 | only 5 significant digits checked, not `6 − szDecimals` | apply both rules |
| Orders on xyz "not visible" | `frontendOpenOrders` without `dex` | request per dex |
| Spot order on same account deleted | spot comes in main-dex response | filter `@`/`/` |
| Bot skips cycles | position TP/SL with `sz:"0.0"` causes the strict parser to reject the snapshot | strict validation only for managed orders |
| Any order "trigger" | `triggerPx:"0.0"` truthy | `isTrigger === true || Number(triggerPx) > 0` |
| Position remains open when it should have been closed | narrow IoC cap on a reduceOnly exit did not cross the book because of lag or during a 429 storm | exit with wide slippage + reliable-close queue |
| IoC entry does not acquire a position | IoC at mid; spread wider than the cross | cross wider than the spread; check the spread |
| Hidden remainder after "full" closure | reduceOnly trimmed by real position, size from old snapshot | re-read REST after close; remainder if `fresh > filled + szTick` |
| Duplicate re-entry or partial reduction | positions and orders read in parallel | positions → orders → positions, compare `szi` |
| Orphaned SL closes new position | reduceOnly-trigger outlived flat | explicit cancel oid on full close |
| Short through zero during bot pause | cancel by side, not `reduceOnly` | cancel all `reduceOnly !== true`, unknown as opening |
| Protective TPs are repeatedly removed | book is cleared before the leverage check, and the leverage rejection repeats | check leverage before cancellations; block only opening orders |
| Manual user orders canceled by bot | no cloid, bot considers everything per coin its own | separate account/subaccount; cloid from day one |
| Extra weight spent on reconciliation | 4xx/429 treated as an “unknown outcome” | 4xx/429 are known outcomes |

---

## Open questions / not verified

- **Does HL accept reduceOnly IoC with `sz × px < $10`** (closing dust without bump)? Not verified live. Implementation options: pad full close to ≥ $10 (exchange will truncate by position); send "any size" close; interpret "minimum value" error as dust and stop attempts. Bump size of full close to minimum dust closes it (observation 2026-07-14): order passes minimum, reduceOnly trims execution by position. Reliable option: bump.
- **Weight `openOrders`**: early record (2026-06) — 2, late (2026-09) — 20, as with `frontendOpenOrders`. Adopted 20 as more recent. Recheck against current weight table.
- **Fields `openOrders`**: early record "fields identical to `frontendOpenOrders"` contradicts later ones where `openOrders` does not return `reduceOnly`/`isTrigger`/`isPositionTpsl`/`orderType`/`cloid`. Adopted latter. If flags needed, use `frontendOpenOrders`.
- **Weight of exchange requests and batches**: official formula for batch weight and maximum batch size not recorded here (check section on limits).
- **modify/batchModify:** whether oid and queue priority are preserved, and which errors occur, are not verified. `cancelByCloid`, the `cloid` format, and `scheduleCancel` (only the `scheduledCancel` status is documented here) are not verified.
- **Subscription `orderUpdates`**: known statuses and that `sz` = remainder. Exact frame shape and fields not recorded.
- **Exact rejection strings:** reduceOnly and non-crossing IoC wording was not verified live. For Alo, the question is partially answered: on mainnet (2026-09-14), rejections actually come in `statuses[i].error` and are caught by regex `/immediately|post only|alo/i`, but exact full text was not recorded (§11).
- **Clamping oversized reduceOnly on HL:** there is no official description; observations (ceil-rounded close sizes larger than the position succeed without rejection) support it.
- **`orderStatus` retention:** it is unknown how long it takes for an oid to become `unknownOid`.
- **Response without `statuses`:** possible interpretations are `SUBMITTED` or “not confirmed / REJECTED.” Chosen rule: treat it as neither executed nor definitely unsubmitted; reconcile the book.
- **Price rule for spot** (`8 − szDecimals`) not verified on spot orders.
- **Tick size at `px ≥ 1e4`**: options — round to nearest integer (tick size 1) or keep only 5 significant digits (`123456.7` then has tick size 10). Both are accepted by exchange. Difference only in precision, important for passive orders at best price.
- **Submission order within a tick** (IoC → protective Gtc RO → the rest) is a recommendation, not verified.
- **Exact `orderType` values** for trigger orders (other than `'Limit'`) not fixed: match regular expressions `/stop/i`, `/take\s*profit/i`.
- **Submission latency:** raw HL submission latency was not measured separately; the seconds per batch in §14 include the bot's own throttler.

---

Verification dates appear in the text. The HL API changes, so recheck limits and response shapes.

---

<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting the material, credit “markpaper — Hyperliquid knowledge base” and link to the original and the license._
