A guide to risk management on Hyperliquid (HL): leverage (updateLeverage), cross and isolated margin, ROE and position margin, liquidation, the utilization ceiling and rejection loop, portfolio drawdown, unprotected “naked” positions, native exchange TP/SL versus software stops, reliable closing, and exit priority. Code examples target SDK @nktkas/hyperliquid 0.27.x. Collateral modes (Unified Account, portfolio margin, and dex abstraction) are covered in detail in accounts.md.
TL;DR
updateLeverage({ asset, isCross, leverage }):leverage = max(1, min(floor(x), meta.maxLeverage)),isCross = !meta.onlyIsolated. Most HIP-3xyzdex pairs haveonlyIsolated: true, so HL rejects a hard-codedisCross: true. Leverage above an asset'smaxLeverageis also rejected (for example,xyz:HOOD10x andxyz:SP50050x). Set leverage before the entry order. The call is idempotent but takes 0.3–1.5 s, so cache it.- Per-coin leverage is visible in
clearinghouseStateonly while a position is open (assetPositions[].position.leverage). The field is absent without a position. - ROE =
unrealizedPnl / marginUsed(approximately price move × leverage).returnOnEquitydivides by entry margin, while crossmarginUsedis calculated from mark price. The values diverge after the price moves. Stops and the UI must use the same formula. - Liquidation (approximation):
ROE_liq = leverage / (2 × maxLeverage) − 1. At maximum leverage, liquidation occurs at −50% ROE, corresponding to a price move of1/(2·maxLeverage). A −50% ROE stop leaves no buffer. - Do not load margin to the ceiling. Near 100% margin utilization, new orders can be rejected indefinitely with
Insufficient margin: open orders reserve margin themselves, with fees, price drift, and uPnL adding more pressure. Keep utilization below that level with headroom. Detect a shortage using aggregatewithdrawable: Unified Account collateral is shared across all dexes. - A software stop without exchange-side protection leaves the account unprotected while the backend is down or throttled by 429s. Place a native
positionTpslpair (s: '0',r: true,trigger.isMarket: true) several ROE points beyond the software stop. - Native TP/SL orders trigger on mark price. HL returns
positionTpslstatuses as strings without oids. Find the oids throughfrontendOpenOrders; for HIP-3, make that request withdex. - No gate blocks an exit. Closing bypasses all limits and keeps attempting reduceOnly IoC until the position is flat. Gate only actions that open or increase a position. Every exit order uses
reduceOnly; otherwise it can flip the position through zero. - A degraded read means skipping the tick, not “zero” or “empty account.”
- Calculate account drawdown from
portfolioover the monthly window, notallTime. TheallTimecurve starts at 0 when the account begins, so it produces 70–100% drawdown for an ordinary account.
1. Position State and Basic Margin Formulas
Read state from info.clearinghouseState({ user }) for main perp and from info.clearinghouseState({ user, dex: 'xyz' }) for each HIP-3 dex (one separate request per dex).
| Field | Type | Meaning / nuance |
|---|---|---|
assetPositions[].position.szi | string | Signed size: >0 LONG, <0 SHORT. There is one position per coin (one-way) |
.entryPx | string | Average entry price |
.positionValue | string | Mark-price notional |
.unrealizedPnl | string | Unrealized PnL |
.marginUsed | string | Position margin. For cross, calculated from mark: size × mark / leverage |
.returnOnEquity | string | HL's ROE, whose denominator is entry margin |
.leverage | { type: 'cross' | 'isolated', value: number, rawUsd: string } | value is the multiple. For isolated, rawUsd reflects debt |
marginSummary.accountValue | string | Perp equity for this dex |
marginSummary.totalMarginUsed | string | Total margin in use on the dex |
withdrawable | string | Amount available to withdraw. The best measure of actual free capacity |
Formulas:
- Position margin:
|positionValue| / leverage. Ifleverage ≤ 0or the field is absent, treat margin as zero. - Fallback margin calculation when
marginUsedis absent or≤ 0(confidence: medium): forisolatedwithrawUsd ≠ 0, use|positionValue + rawUsd|; forcross, usesize × entryPx / leverage. If the fields are absent, default toleverage.value = 1,leverage.type = 'cross'. - Margin for the position and resting entry orders:
marginToHold = (curNotional + Σ px·sz of entry orders) / L. - Gross account leverage:
grossLeverage = Σ|positionValue| / equity(0 ifequity ≤ 0). - Account margin ratio including spot:
(Σ totalMarginUsed across dexes) / (Σ accountValue across dexes + free spot stablecoins). If the denominator is 0, ratio = 0. On a Unified Account, spot stablecoins are part of collateral, so omitting them from the denominator overstates the ratio. - Buying power:
accountValue × leverage. Open orders also reserve margin, so when the position plus resting orders approaches buying power, the exchange begins rejecting orders for insufficient margin.
One-way mode. A coin can have only one side. A non-reduceOnly order on the opposite side reduces or flips the position. If two independent entry strategies use the same account, one strategy's LONG can pass as OPEN and reduce the other's SHORT, while a guard keyed by (coin, side) will not see the other side. Before a fresh entry, check the opposite side both on the exchange and in your local record.
2. updateLeverage
2.1 Call
import type { ExchangeClient } from '@nktkas/hyperliquid';
type AssetMeta = { assetIndex: number; maxLeverage: number; onlyIsolated?: boolean };
// HL retains per-asset leverage on the account between trades, so another call
// with the same value is an empty ~0.3–1.5 s round trip on the critical order path.
const lastSetLeverage = new Map<number, string>(); // assetIndex -> "lev:isCross"
export async function syncLeverage(exchange: ExchangeClient, meta: AssetMeta, wanted: number) {
const leverage = Math.max(1, Math.min(Math.floor(wanted), meta.maxLeverage));
const isCross = !meta.onlyIsolated; // HL rejects isCross=true for onlyIsolated assets
const key = `${leverage}:${isCross}`;
if (lastSetLeverage.get(meta.assetIndex) === key) return;
await exchange.updateLeverage({ asset: meta.assetIndex, isCross, leverage }); // idempotent: retry on 429/5xx/network
lastSetLeverage.set(meta.assetIndex, key);
}
- For HIP-3,
assetis the composite assetId (seesdk-and-api.md). ReadmaxLeverageandonlyIsolatedfrom the relevant dex'smeta.universe. - Pass margin mode explicitly, with no hidden fallback: use
crosswhen the asset is notonlyIsolated. - The cache lives in process memory and resets on restart: the first order after startup will set leverage again. Trade-off: if leverage is changed manually in the HL UI between orders, the order will use that value. Position notional does not change (order size determines it); only margin changes.
- Instead of a cache, compare against the live position:
Math.max(1, Math.floor(existing.leverage)) === Math.max(1, Math.floor(target))means no call is needed. There is no position yet on OPEN, so always set leverage then. If leverage was changed manually,existing.leverageshows the actual value and leverage returns to the target. - Set leverage lazily: only when the tick contains a non-reduceOnly placement for the coin. There is no need to call it on every tick. Validate configured leverage against
meta.maxLeverageduring initialization. updateLeverageat bot startup also serves as the first signed request that verifies the agent is approved.
2.2 When HL Rejects the Request and What to Do
| Situation | Result | Correct handling |
|---|---|---|
leverage > meta.maxLeverage | Rejected | Cap at the asset's maxLeverage |
isCross: true on an onlyIsolated asset | Rejected | isCross = !meta.onlyIsolated |
| An isolated position is open but cross is required | Cannot switch | Preflight reads position.leverage.type before the call. Close the position or switch it to cross in the UI |
Lowering leverage on an open isolated xyz position | Isolated position does not have sufficient margin available to decrease leverage | Treat leverage synchronization as best effort (see below) |
| Changing leverage with any open position | May be rejected | If it is already {type:'cross', value == target}, do not send. On error, advise: “with an open position, change leverage in the UI or close the position” |
2.3 What to Do If Leverage Was Not Set
- Do not abort the entire batch. Cancellations, reduceOnly reductions, and TP maintenance continue. Hold back only opening (non-reduceOnly) orders: otherwise they would use the account's current leverage, possibly the maximum. If reduceOnly orders are also held back, protective TPs for a coin whose leverage update keeps failing will never be placed.
- Log prominently that the entry would use the account's current leverage and therefore a different margin requirement. Remove the coin from the “leverage set” collection so the next tick retries it.
2.4 Where Position Leverage Is Visible
- A fill does not contain leverage. Read it from the account's
clearinghouseState, atassetPositions[].position.leverage.value. The field is absent without a position in the coin, even if the account has resting orders. - Changing a position's leverage changes
marginUsedand ROE. The leverage stored in your position record must be updated both when increasing the position and when changing leverage. Otherwise price-derived ROE and the stop level drift.
3. Cross and Isolated
| cross | isolated | |
|---|---|---|
| Collateral | Shared account (dex) margin pool | Margin allocated to a specific position |
marginUsed | size × mark / L (from mark) | ≈ size × entry / L (from entry), leverage.rawUsd |
| ROE level → price | px = entry / (1 − s) | px = entry × (1 + s) |
| Switching mode with an open position | — | Cannot switch to cross |
HIP-3 xyz | Unavailable for most pairs (onlyIsolated: true) | Normal mode for HIP-3 equities |
What updateLeverage changes | Not position size (the order determines it), but required collateral | Position collateral and liquidation point |
Here s = dir × ROE / (100 · L), with dir = +1 for LONG and −1 for SHORT (see §9.3).
Practical rules:
- Multiple cross positions share one liquidation buffer. Crypto pairs are strongly correlated, so they are weak buffers for one another: they tend to fall together.
- On an account without shared collateral, each dex has its own collateral: main-account equity does not back
xyzorders, so calculate exposure and free margin separately for each dex. On a Unified Account, collateral is shared; see §6 andaccounts.md.
4. ROE: One Formula on Every Path
| Formula | When to use it |
|---|---|
roePct = unrealizedPnl / marginUsed × 100 (0 if marginUsed ≤ 0) | Primary formula. Identical on WS and REST paths |
roe = DIR × (price − entry) / entry × leverage × 100 | Fast price tick: requires only your average entry price, leverage, and one price per coin. Matches the primary formula for isolated |
roe = DIR × (mark − entry) / mark × leverage × 100 | Exact cross form (marginUsed uses mark in the denominator) |
HL's returnOnEquity | Do not use for stops: its denominator is entry margin; on cross it diverges from marginUsed after price moves |
- −50% ROE means losing half the margin, not a 50% price decline.
- Skip positions with
marginUsed ≤ 0in ROE stops: their ROE cannot be calculated reliably. - An ROE stop scales with leverage: −10% ROE at 20x is a 0.5% price move, while at 4x it is 2.5%. The same ROE level at different leverage implies a very different price move.
- After increasing a position (INCREASE):
- Calculate the new average entry price without REST:
(prevSize × prevEntry + fillSz × fillPx) / (prevSize + fillSz). Takeprevfrom the pre-order snapshot. If the snapshot has noentryPx, usefillPx: it is less accurate than the true average, but better than stale triggers. - Margin grows while dollar profit remains the same, so ROE falls mechanically. A position at +50% with $80 of margin, after increasing margin to $160, will show +25%. Recalculate ROE-denominated levels that were stored before the increase.
- Re-place native trigger prices from the new average (§9.2).
- Calculate the new average entry price without REST:
5. Liquidation
5.1 Approximate Formula
Maintenance margin is approximately half the initial margin at the asset's maximum leverage, or 1/(2·maxLeverage) of notional.
// ROE (in %) at which the position is liquidated. Approximation: base tier,
// excluding other cross collateral and funding.
function liqRoePct(leverage: number, maxLeverage: number): number {
const maxLev = maxLeverage > 0 ? maxLeverage : leverage;
if (maxLev <= 0) return -100;
return -(1 - leverage / (2 * maxLev)) * 100;
}
// price move to liquidation = 1/leverage − 1/(2·maxLeverage)
| leverage | maxLeverage | Liquidation ROE | Price move to liquidation |
|---|---|---|---|
| 40 | 40 | −50% | 1.25% |
| 20 | 20 | −50% | 2.5% |
| 10 | 20 | −75% | 7.5% |
| 10 | 50 | −90% | 9% |
| 1 | 10 | −95% | 95% |
- The closer selected leverage is to the maximum, the closer liquidation is in ROE terms. At maximum leverage, a −50% ROE stop leaves no buffer before liquidation.
5.2 Diagnosis: “Closed by the Stop” or Liquidated?
Forced-liquidation fills in userFills carry the liquidation flag. When investigating a report that “the stop or bot closed every position,” first check the account's userFills for this flag. Liquidation is indicated by all closing fills carrying liquidation and accountValue → 0.
6. Margin Utilization and the Rejection Loop
6.1 The Insufficient margin Loop at the Margin Ceiling
- Symptom: near 100% margin utilization, HL rejects some orders forever with
Insufficient margin, even though there is no real incident. - Cause: open orders reserve margin themselves, and actual reservations are slightly larger than calculated because of fees, price drift, and uPnL, leaving less free collateral than the required order size.
- Diagnostic trap: this is global collateral exhaustion, not overload on one dex. Different per-dex utilization percentages are an artifact of dividing by per-dex available amounts. On a Unified Account, collateral is shared across all dexes. Detect the shortage using aggregate
withdrawablefrom every dex'sclearinghouseState. - Remedy: use a utilization ceiling with headroom: a buffer no smaller than the largest order, or back off repeated identical rejections.
6.2 Minimum Order
- HL's minimum order is $10. If the calculated size is smaller, raise the order to the minimum or skip it; calculate exposure from actual order sizes.
6.3 Read Errors
- A missing response field does not mean 0: do not write
?? 0foraccountValuewithout a freshness flag. - Never reduce a real position based on untrusted equity. A bad read turns a routine reduction into a large market sell-off. Holding a reduction costs nothing (it repeats on the next good tick), while an excess reduction is irreversible.
- Raise peak equity using the median of a time-spanning window, not one read. A
max(peak, accountValue)ratchet based on one smoothed read is dangerous: HL emits bursts of overstated readings, and one such burst would raise the peak forever.
6.4 Reflexivity of Perp accountValue — Self-Amplification
Do not size from perp accountValue without free spot. If perp equity is the sizing denominator, a positive feedback loop appears: reduce orders → release hold → perp accountValue falls → targets fall → reduce again. With total capital unchanged, the perp leg shrinks to a small fraction of capital within minutes, and the bot cuts positions with market-like IoC reduceOnly orders for no external reason.
- The sizing denominator must not depend on the strategy's own orders and positions: calculate capital as perps + free spot.
7. Drawdown and Its Windows
7.1 Account Drawdown from portfolio: Monthly Window Only
Calculate account drawdown from the portfolio info request only over the monthly window. The allTime curve begins at account inception with "0.0", so it produces 70–100% drawdown for an ordinary account.
7.2 The Perp Leg and Spot on Unified Account and Portfolio Margin
- One “capital” number answers two different questions: “how large is it?” and “has it been lost?” Capital for sizing is calculated as “perps + free spot.”
- Reallocations are not withdrawals. If funds are held in spot or Earn and moved into perps only for a position, every move to flat zeros the perp leg, so moving funds to spot or returning them from Earn looks like a drop in perp equity. Before concluding that funds were lost, decompose equity into perps and free spot.
- Stablecoin
totalandfreeon Unified Account see different things.totalis blind to a perps→spot reallocation: it is bit-for-bit identical before and after even though the perp leg disappeared.freeis blind to funds committed to spot bids. For funds to have truly left, both values must decline. - Manual “withdrawal or reallocation” check: add perps and free spot at two points. If the sums match, it was a reallocation. Unified Account signature:
spotTotalmatches at both points whilespotFreediffers.
8. “Naked” Positions
A “naked” position is an open position without any protective reduceOnly order. It is the earliest visible sign that protection has failed.
8.1 HL Property: Resting ReduceOnly Is Bounded by the Live Position
HL bounds execution of a resting reduceOnly order by the live position and rejects the order when the position is flat. Therefore protective reduceOnly TPs can remain resting through an ambiguous close: they cannot open a position.
8.2 Invariants
- Every exit order carries a real
reduceOnlyflag—both IoC closes and resting TPs—so a flip through zero is impossible. A regular exit limit order does not stop at zero and will reverse the position. Total TP order size must not exceed the position. If there is no position, cancel every exit order. - A degraded account read (
ok=false, no prices) means skipping the tick. Never interpret it as “the account is empty.”
9. Stop Losses: Native TP/SL and Software Stops
9.1 Comparison
Native positionTpsl | Software stop | |
|---|---|---|
| Where it executes | HL matching engine | Your process |
| Trigger | Mark price (HL docs: “TP/SL are triggered by mark price”); a single order-book wick is harmless | Whatever you calculate: ROE from a WS snapshot or price from allMids |
| Backend down, 429 storm, WS stalled | Works | Does not work |
| Moving levels (trailing and similar) | Expensive: every move is an exchange request and can cause a rate-limit storm | Cheap |
| Accuracy | Approximation: margin mode is unknown and funding is ignored | Exact live ROE |
| Accounting (PnL, notifications) | Must be reconciled through orderStatus | Immediate |
| Reaction | Exchange-side on mark | WS push (~1 s) + reduceOnly IoC (~1–2 s) |
| Role in protection | Backstop and emergency floor at a farther offset | Primary stop |
Failure class “backend is down or throttled by 429s”: software stops do not execute, while stale decisions execute late, leaving the position unprotected for minutes. The response has four parts: native TP/SL as a backstop, a decision-age gate (§10.4), a close reconciler (§10.2), and timeouts on every fetch to HL.
9.2 Placing the Native Pair
Format (verified live):
grouping: 'positionTpsl';s: '0'means the order automatically follows the full position size; it does not need resizing after an increase or partial close;r: true(reduceOnly) is mandatory;bis the exit side, opposite the position;t.trigger = { isMarket: true, triggerPx, tpsl: 'sl' | 'tp' };pis the worst acceptable execution price after triggering (the slippage bound, §9.3);- send both legs in one exchange call; its weight equals a single order under HL's
1 + floor(batch/40)formula; - when one leg fills, the exchange cancels the other automatically (
siblingFilledCanceled). If the position is closed another way, reduceOnly triggers are canceled asreduceOnlyCanceled.
import type { ExchangeClient, InfoClient } from '@nktkas/hyperliquid';
type Leg = { tpsl: 'sl' | 'tp'; triggerPxStr: string; limitPxStr: string }; // prices are already rounded by szDecimals
export async function placePositionTpsl(exchange: ExchangeClient, info: InfoClient, p: {
user: `0x${string}`; assetIndex: number; coin: string; isBuy: boolean; legs: Leg[];
}) {
// NOT idempotent: retry only on 429 (rejection before execution). A duplicate
// placement after an ambiguous 5xx is worse than having no backstop.
const res = await exchange.order({
orders: p.legs.map((leg) => ({
a: p.assetIndex,
b: p.isBuy, // true = buy to exit (SHORT position)
p: leg.limitPxStr, // slippage bound after triggering
s: '0', // positionTpsl: automatically tracks full size
r: true,
t: { trigger: { isMarket: true, triggerPx: leg.triggerPxStr, tpsl: leg.tpsl } },
})),
grouping: 'positionTpsl',
});
// statuses arrive as STRINGS ("waitingForTrigger" / "resting") WITHOUT an oid.
// Support object forms {resting:{oid}} / {filled:{oid}} / {error} as a fallback.
// The SDK throws ApiRequestError if AT LEAST ONE status is an error: recover statuses
// from the error payload, and if any order was placed, still find its oid (or the live trigger is invisible).
// Find the oid in frontendOpenOrders. HIP-3 orders are NOT returned without dex; response coin has the "xyz:" prefix.
const isXyz = p.coin.startsWith('xyz:');
for (let attempt = 0; attempt < 2; attempt++) {
const orders = await info.frontendOpenOrders(isXyz ? { user: p.user, dex: 'xyz' } : { user: p.user });
const trig = orders.filter((o: any) => o.coin === p.coin && o.isTrigger && o.reduceOnly);
// match: orderType /stop/i for sl, /take\s*profit/i for tp + |triggerPx − target| ≤ max(target·1e-5, 1e-9)
// ... return if both oids are found; otherwise retry after 500 ms because HL does not register the order immediately
if (attempt === 0) await new Promise((r) => setTimeout(r, 500));
}
return res;
}
Lifecycle rules:
- Place the pair after the entry fill, using the actual fill price and awaiting it under the position mutex: the next task for that position must see the stored oids.
- A position must not have two pairs. Before placing a new pair, cancel the old orders by oid (re-placement on INCREASE). After increasing the position, change only the trigger prices, based on the new average entry.
- Return partial success (one leg placed, the other not) as-is; the caller decides what to store.
- If the bot charges a builder fee, do not attach it to protective orders: stale approval must not break a stop.
- Placement is best effort: log an error without breaking the main flow.
- The native pair is an emergency floor, not a copy of the client-side stop. Every re-placement is an exchange request, so the client owns moving levels while the exchange level remains farther away and is not moved on every price change. While the backend is alive, client-side levels are tighter and trigger first.
- Calculate the native level using the same rule as the client-side level (the same stop mode and the same input data, even if stale). Otherwise, at high leverage a native ROE stop can be closer in price than the client-side stop and cut the position first.
- When native stops are disabled, cancel the existing pair and clear stored oids: do not leave triggers at stale levels without management. Keep reconciliation of triggered orders (§9.4) enabled; it only reads the exchange and writes the local record, and sends no orders.
9.3 ROE Level → Trigger Price
ROE = uPnl / marginUsed, uPnl = size × (px − entry) × dir. The denominator depends on margin mode:
- isolated:
marginUsed ≈ size × entry / L→px = entry × (1 + s); - cross:
marginUsed = size × mark / L→px = entry / (1 − s); - where
s = dir × ROE / (100 · L),dir = +1for LONG and−1for SHORT.
The mode may be unknown at calculation time, so use the formula farther from entry for each leg: the native stop then triggers later than the client-side stop in either mode.
function deriveLeg(side: 'LONG'|'SHORT', entryPx: number, leverage: number, roePct: number, closeSlippagePct: number) {
if (!(entryPx > 0) || !(leverage >= 1)) return null; // leverage 0.5 → null
const dir = side === 'LONG' ? 1 : -1;
const isBuy = side === 'SHORT'; // exit is on the opposite side
const s = (dir * roePct) / (100 * leverage);
if (s >= 1) return null; // degenerate leg: mark-based level is unreachable
const triggerPx = s > 0 ? entryPx / (1 - s) : entryPx * (1 + s); // above entry → mark-based, below → entry-based
if (!(triggerPx > 0)) return null; // lower leg with a negative price (1x, SL 120+3)
const limitPx = triggerPx * (isBuy ? 1 + closeSlippagePct / 100 : 1 - closeSlippagePct / 100);
return { isBuy, triggerPx, limitPx };
}
// SL: deriveLeg(side, e, L, -(slPct + offsetPct), slip); TP: deriveLeg(side, e, L, +(tpPct + offsetPct), slip)
// no configured level → no leg; if one leg is null, the other remains
Examples (synthetic: entry 100, SL 10 / TP 30, offset 3 → native 13 / 33, closeSlippagePct 4):
| Position | Leverage | Leg | Trigger | Limit |
|---|---|---|---|---|
| LONG | 10x | SL | 98.7 (100 × (1 − 0.013)) | 94.752 (× 0.96) |
| LONG | 10x | TP | ≈ 103.41 (100 / 0.967; entry-based would give 103.3) | × 0.96 |
| SHORT | 10x | SL | ≈ 101.32 (100 / 0.987; entry-based 101.3) | × 1.04 |
| SHORT | 10x | TP | 96.7 | × 1.04 |
| LONG | 1x | SL | 87 | |
| LONG | 1x | TP | ≈ 149.25 (100 / (1 − 0.33): cross ROE there is +33%) | |
| SHORT | 1x | TP | 67 | |
| LONG | 50x | SL | 100 × (1 − 13/5000), or 0.26% of price | |
| LONG | 1x | TP 100+3 | null (s = 1.03) | |
| LONG | 10x, offset 0 | SL 10 | 99 (matches the client-side level) |
- Shift the limit in the execution direction by
closeSlippagePctso the trigger executes like a market order. The exit deliberately has a wide bound. - An
offsetof 0 is safe (both paths are reduceOnly), but accounting is noisier because the native and client-side stops race.
9.4 Reconciling Triggered Native Stops
There are two paths; neither can be removed.
-
Periodic reconciler, always enabled. For every record with native oids:
- position is live → do nothing;
- position disappeared → request
orderStatusfor each oid:- any
filled→ the exchange closed the position with a trigger while the backend was down or slow → account for it as a full close (PnL, journal, notification); - all terminal and none
filled→ the position was not closed by the bot (it was closed manually in the HL UI or liquidated) → clear only the oid association;
- any
- the bot's own close is already in flight → skip;
- positions are unknown (HL unavailable) → do nothing.
Under the position lock, verify the record identity using the open time and both oids.
-
Self-healing a stale record. A position may have disappeared because of its own trigger in the window between the trigger fill and the reconciler tick. Simply deleting the record would lose the stop-out accounting. Before deleting a record with live oids, therefore make 1–2
orderStatusrequests:filled→ full close accounting;unknownOid→ it was not closed by its own trigger, so do not record a triggered close.
const st = await info.orderStatus({ user: '0xYOUR_ADDRESS', oid });
// expected forms: { status: 'order', order: { status: 'filled' | 'canceled' | ... } } or { status: 'unknownOid' }
- Record a native-trigger close in your position accounting, or a dangling open record will remain.
9.5 Software Stop Engine
- Tick sources:
- a WS-pushed account snapshot (the fastest point is immediately after updating the snapshot), plus a safety sweep in case pushes were missed;
- take the position list WS-first, with a bounded REST fallback for a stale snapshot only, not on every tick;
- Cached REST account polling updates ROE only every tens of seconds. This is tolerable for a fixed stop but not for a trailing stop: on a fast reversal it gives back everything that happened between samples. The solution is an
allMidsprice tick: one request cached for 2 s for all positions, with O(coins) rather than O(positions) cost. Guard the tick against overlap when HL is slow.
- Anti-spam: while a close order is in flight, keep the position key
(coin, side)ininFlight. Rate-limit repeated triggers for the same key (important when closing fails while pushes continue). - Close with a reduceOnly IoC (§10.1) under the position mutex, using dex abstraction for HIP-3.
9.6 Stop Types: What Matters for HL
The levels below are in ROE; calculate the trigger price from ROE as described in §9.3.
- ROE stop and take profit: close at
ROE ≤ −slPctorROE ≥ +tpPct. - Price-move stop: convert it to ROE by multiplying by leverage: an x% price move is approximately
x × leverageROE points (§4). At high leverage, such a stop may lie beyond liquidation: compare it with liquidation ROE from §5 and do not place it farther away. - Moving stops (trailing, volatility-derived levels, tightening with holding time) belong on the client: every native-trigger move is an exchange request (§9.1). The levels themselves are a strategy concern. For HL, the key point is that the native pair remains the emergency floor (§9.2).
- Do not run two engines for the same stop with different price sources: moving levels live in one place; the second path is only account-level protection.
10. Account Protection: Exits and Queues
10.1 “Market” Close on HL
The API has no market-order type: close with an IoC reduceOnly limit order offset from mid for slippage.
// use a fresh REST (allMids) mid for closing, not one from a WS frame
const px = isBuy ? mid * (1 + slipPct / 100) : mid * (1 - slipPct / 100); // slipPct grows after each failed attempt
await exchange.order({
orders: [{ a: assetIndex, b: isBuy, p: roundPx(px), s: sizeStr, r: true, t: { limit: { tif: 'Ioc' } } }],
grouping: 'na',
});
- Start with small slippage and increase it after each failed attempt.
10.2 CLOSE Is a Mandatory Event
- Entry gates block entry only. A blocked exit is a leveraged position left moving against the account, most often during volatile periods and a 429 storm.
- Reliable-close reconciler: if a close fails transiently or fills partially, enqueue the position and periodically keep submitting reduceOnly orders until it is flat. Retry points include:
nullaccount state (429/network);- no mid price;
- no metadata;
- failure enabling dex abstraction;
REJECTED/SUBMITTEDwithout a fill;- an exception while submitting;
- a partial fill.
- Use a wide slippage “floor” for exits so a thin book does not prevent closing.
10.3 Request Queue Priority
A token bucket with three tiers, FIFO within each tier:
| Tier | Contents |
|---|---|
urgent | reduceOnly CLOSE |
high | Other exchange actions (OPEN/INCREASE, updateLeverage, dex abstraction) and info reads on the trading-decision path |
normal | Background info reads (metadata refresh, analytics) |
Reason: during a 429 storm, a CLOSE in a shared FIFO sits behind a batch of INCREASE requests and waits for minutes while the position moves against the account. Starving INCREASE is safe (underfilling means less risk); starving CLOSE is not. This is pure reordering and does not change the rate-limit budget.
10.4 Do Not Execute a Stale Decision
await calls on the decision path pass through the throttle and may hang for minutes during a 429 storm. Without a gate, an opening order can execute minutes later after the price has moved, or reopen a position already closed by a stop. Rule: do not execute OPEN/INCREASE older than the configured threshold measured from decision time; never gate CLOSE.
10.5 Preflight Before Going Live
Before the first live run, perform an order-free pass that checks and prints:
- address role (
userRole); - agent approval with one signed request (
updateLeverage); - fees (
userFees); - remaining address-based capacity (
userRateLimit); - account state across all dexes, effective sizes, leverage, and the market's
maxLeverage/onlyIsolated.
11. Pitfalls
| What breaks | Why | Correct handling |
|---|---|---|
updateLeverage rejected on xyz | isCross: true on an onlyIsolated pair or leverage > maxLeverage | isCross = !meta.onlyIsolated, min(floor(lev), maxLeverage) |
| Extra 0.3–1.5 s on the order path | updateLeverage before every order | Cache assetIndex → lev:isCross or compare with live-position leverage |
| Protective TPs for a coin are never placed | A failed updateLeverage (Isolated position does not have sufficient margin available to decrease leverage) held the whole batch | Best-effort leverage: hold only opening orders |
| Per-coin leverage cannot be read | The leverage field is absent without a position in the coin | Check for a position; do not report leverage without one |
| The stop and UI show different ROE | returnOnEquity (entry margin) versus uPnl / marginUsed (mark on cross) | Use uPnl / marginUsed everywhere |
| ROE levels drift after increasing a position | Margin grew and ROE fell | Recalculate stored ROE levels |
Endless Insufficient margin on some orders | Margin utilization is too close to actual reservations for open orders | Ceiling with headroom; diagnose using aggregate withdrawable |
| Positions shrink by themselves within minutes | Sizing from perp accountValue, which is reflexive to the strategy's own orders | Use a “perps + free spot” denominator independent of your own orders |
| Position closed in the wrong direction | Direction was not derived from the position itself | Derive direction from the sign of szi (§1) |
| Another strategy's entry on the same account reduced the position | One-way mode: a non-reduceOnly order on the opposite side cuts the position | Check the opposite side before entry |
| Position remains unprotected for minutes | Backend throttled by 429s, software stop unavailable, CLOSE queued behind INCREASE | Native positionTpsl, CLOSE priority, decision-age gate |
| Triggered native stop was not accounted for | Record was deleted without checking the native trigger fill | Call orderStatus before deleting the record |
| Native stop triggers before the client-side stop | Different rules or a non-conservative “ROE → price” formula | Same level calculation, farther offset, formula farther from entry |
| Native-stop oid not found | positionTpsl responds with strings without oids; for HIP-3, frontendOpenOrders without dex is empty | Match coin + isTrigger + reduceOnly + orderType + triggerPx; for HIP-3 include dex; make 2 attempts 500 ms apart |
| Live trigger is invisible after an SDK error | ApiRequestError when one leg has an error but the other was placed | Parse statuses from the error payload and fetch the oid |
| Reallocation into Earn or spot is interpreted as a loss | Only the perp leg or only spot total on Unified Account is examined | Perps + free spot; on Unified Account check both total and free |
| Peak equity is overstated forever | Ratchet based on one read while HL emits bursts of overstated values | Raise it from the median of a time-spanning window |
| “The stop closed everything,” but it was liquidation | The exchange, not the bot, closed it | Check the liquidation flag in userFills |
portfolio drawdown is 70–100% | allTime starts at 0 | Use the monthly portfolio window |
| Panic causes an excess position reduction | Decision used untrusted equity | Reduce only on trusted, fresh equity |
12. Open Questions / Not Verified
- Does leverage affect liquidation price on cross? One claim is that on cross, leverage “reduces collateral and moves the liquidation price.” In the approximate model, however, maintenance margin depends on the asset's
maxLeverage, not selected leverage. Leverage probably changes only initial margin on cross (how much can be opened or withdrawn), while liquidation depends on all account collateral versus aggregate maintenance. Not checked against HL documentation and not verified on an account. - The liquidation formula is an approximation: base margin-table tier, excluding cross collateral and funding. Behavior at large notional (tiers with lower
maxLeverage) has not been analyzed. The position'sliquidationPxfield is not covered here. - The semantics of isolated
marginUsed(whether it includes uPnL and howrawUsdbehaves) are only indirectly confirmed. Confidence in the fallback margin calculation is medium. - Exact HL error messages for
leverage > maxLeverageandisCross: trueononlyIsolatedwere not retained; only the rejection itself is known. - The
orderStatusresponse shape (filled,unknownOid, and other terminal statuses) is known from observed responses; the complete status list has not been checked against documentation. - HIP-3 dex collateral: on Unified Account it is shared across dexes (§6.1); in manual mode, each dex has its own account (§3). Per-dex limits should be rechecked across account modes (see
accounts.md). - Not analyzed:
updateIsolatedMargin(adding margin to isolated), ADL and partial-liquidation mechanics, the effect of funding on margin and liquidation level,normalTpsland fixed-size TP/SL, and otherportfoliowindows (day/week, perp-only).
Knowledge snapshot: 2026-09; dates of individual checks 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 this work, credit “markpaper — Hyperliquid knowledge base” and link to the original and the license.