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
- One request:
exchange.order({ orders: [{ a, b, p, s, r, t }], grouping }).p(price) ands(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. - Price: no more than 5 significant digits and no more than
6 − szDecimalsdecimal places for perps. An integer price is always valid. Size is rounded down to the10^-szDecimalslot, with an epsilon before floor (0.29*100 = 28.999999999999996). Size calculation and submission must round with the same function. - 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. - 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.
- Response:
response.data.statuses[i]has one of these forms:{resting:{oid}},{filled:{oid,totalSz,avgPx}}, or{error:"..."}. The SDK throwsApiRequestErrorif even one status is an error. Retrieve statuses of neighboring batch orders that succeeded fromerr.response. - 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. - Order reads are scoped to a dex.
frontendOpenOrders/openOrderswithoutdexdo not show HIP-3 (xyz:) orders; make a separate request withdex:'xyz'. The main-dex response includes spot orders (@85,PURR/USDC). Limit orders returntriggerPxas the truthy string"0.0". Position TP/SL returnsszas"0.0". - 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. - 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. - Cancellation: the response is
'success'or{error}. Errors containingnever placed / already canceled / filledmean “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
// 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, thenorder. On later entries, once leverage is set,orderis enough. The first entry requires two exchange actions, not one (check the exchange-request weight formula in the limits section). updateLeverageis 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
isCrossper market.isCross: trueis rejected for an isolated-only asset (HIP-3 equities are often isolated). A simple rule isisCross = !onlyIsolated, using the flag from meta. - HIP-3 through an agent wallet requires
agentEnableDexAbstraction. The error textAbstraction transition not allowedmeans 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
updateLeverageis 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
historicalOrdershas atiffield; do not require it while parsing. badAloPxRejectedis 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' } }.triggerPxis the trigger level based on mark price;pis the worst acceptable price after triggering.- In
frontendOpenOrdersresponses they haveisTrigger: true; position triggers also haveisPositionTpsl: true. MatchorderTypewith/stop/ifor SL and/take\s*profit/ifor TP. ExactorderTypestrings 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
reduceOnlyflag: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
// 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: trueis required.bis 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',statusesentries 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
frontendOpenOrdersafter placement and match oncoin,isTrigger,reduceOnly,orderType(/stop/i→ SL,/take\s*profit/i→ TP), andtriggerPxwith 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
orderStatususing 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), because82.7165already 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
// 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=0submits 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 RO0.001 @ $5000, szDec=3($5) → skip (bumping to 0.002 would reduce twice as much as intended); partial RO0.29 @ $35, szDec=2($10.15) → submit; full close0.2 @ szDec=0→ ceil to 1 lot; full close0.001 @ $3000, szDec=3→ 0.004 ($12); open0.2 @ $1000, szDec=0→ skip (one lot overshoots 5×); open0.0019 @ $5500, szDec=3→ floor 0.001 = $5.5 → skip; open5 @ $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), onlyszDecimalsand the dollar minimum.
6.2 How to check
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
// 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
{ 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.totalSzandavgPxare strings.oidis a number;Numberis safe while it remains below2^53.positionTpslentries 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.
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
statusesor an empty array → not confirmed ({status:'ok', statuses:[]}is a trap); - unknown entry shape (
{futureShape:true}) → not confirmed; filledwith nonnumerictotalSz('Infinity'), invalidavgPx/oid, ortotalSz > requestedSzwith tolerance 1e-9 → invalid fill;restingwith 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
filledresult 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
clearinghouseStatethrough REST and count a remainder only whenfreshSize > filledSz + szTick. With an exact close, the actual position equalsfilledSz, so there is no false positive.
8. Using IoC
8.1 Walk-the-book before entry
// 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)atszDecimals, 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
// 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
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
oidonly 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.responsewithstatus:'ok'. successdoes 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:
- hold opening orders (not reduceOnly), or the order may fill twice;
- hold partial reduceOnly orders (partial reduction, TP): size was calculated from a stale position and is recalculated for free on the next tick;
- always submit a full reduceOnly close: HL clamps it to the live position;
- 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
openOrdersfor 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
frontendOpenOrdersreturnscloid(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.
cancelByCloidis 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].errorand 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)
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
openOrdersreads. 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
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}withoutdexreturns 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; omitdexfor main. - Spot is included in the main-dex response.
coinmay be@85(spot-pair index, PURR/USDC) orPURR/USDC. A perp bot filters withif (coin.includes('/') || coin.startsWith('@')) continue;; otherwise, “cancel everything absent from config” deletes unrelated spot orders. triggerPx: "0.0"is truthy. Detect a trigger witho.isTrigger === true || Number(o.triggerPx) > 0, notif (o.triggerPx).- Position TP/SL carries
sz:"0.0". Strictpx>0 && sz>0validation for every entry before relevance filtering invalidates the entire snapshot. Apply strict validation only to managed orders (not spot, trigger, or positionTpsl);isFinite && ≥ 0is 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.allfor terminal decisions. Read in this order:clearinghouseState→frontendOpenOrders→clearinghouseState. The snapshot is stable only if the per-coinszimaps 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, whereexpected= position before the order + confirmedfillSize(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 (
FILLEDand 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 |
| 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 withfrontendOpenOrders. Adopted 20 as more recent. Recheck against current weight table. - Fields
openOrders: early record "fields identical tofrontendOpenOrders"contradicts later ones whereopenOrdersdoes not returnreduceOnly/isTrigger/isPositionTpsl/orderType/cloid. Adopted latter. If flags needed, usefrontendOpenOrders. - 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, thecloidformat, andscheduleCancel(only thescheduledCancelstatus is documented here) are not verified. - Subscription
orderUpdates: known statuses and thatsz= 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].errorand 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.
orderStatusretention: it is unknown how long it takes for an oid to becomeunknownOid.- Response without
statuses: possible interpretations areSUBMITTEDor “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.7then 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
orderTypevalues 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.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting the material, credit “markpaper — Hyperliquid knowledge base” and link to the original and the license.