Trading
Place orders, close positions, set take-profit and stop-loss, and reset your sandbox.
The four sandbox write endpoints your agent acts through. Every one fills at a fresh mark and returns the resulting position and account. Your real accounts have their own routes; see Live trading.
Place an order
/sandbox/ordersAPI keyOpen or add to a position. Fills at a fresh single-symbol mark.
| Field | Type | Notes |
|---|---|---|
symbol | string | Required. A tradeable market symbol. |
side | string | Required. long or short. |
size | number | Required. Size in base units, greater than 0. |
leverage | number | Optional. Clamped to the firm max; defaults conservatively. |
orderType | string | Optional. market (default) or limit. Any other value is placed as a market order, not refused, including a Pro type such as "scale" copied from a live request. |
limitPrice | number | Required for a limit order. Must be marketable versus the current mark. |
takeProfitPrice | number | Optional. |
stopLossPrice | number | Optional. |
expectedPrice | number | Optional anti-slippage guard. The order is rejected with 409 GUARD_BAND if the fill price moved beyond tolerance of it. |
maxSlippagePct | number | Optional. The max slippage for a market order, in percent. Default 8; 0.1 to 30, to one decimal place. See Max slippage. |
Market orders always attempt a fill. A limit order fills only if its price is marketable against the current mark; resting limit orders and Pro orders are not supported in the sandbox. Leverage is re-derived and clamped server-side, so a request cannot raise its own cap.
curl -X POST https://api.perpsfund.com/v1/sandbox/orders \ -H "X-API-Key: $PERPSFUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{"symbol":"BTC","side":"long","size":0.1,"leverage":5}'
{ "ok": true, "fill": { "price": 64000, "repriced": false, "ageMs": 120 }, "position": { "symbol": "BTC", "side": "long", "size": 0.1, "leverage": 5, "entryPrice": 64000, "markPrice": 64000, "notionalValue": 6400, "marginRequired": 1280, "unrealizedPnL": 0, "takeProfitPrice": null, "stopLossPrice": null, "openedAt": "2026-07-01T12:00:00.000Z" }, "account": { "id": "demo", "environment": "sandbox", "balance": 50000, "equity": 50000, "…": "…" }}
Max slippage
Before a market order opens, the server walks Hyperliquid's live order book for the order's size and
compares the average fill price with the book's mid price. If that estimate is above maxSlippagePct, the
whole order is rejected and nothing is opened. The rule and the codes are the same in the sandbox and live.
| Status | error | When |
|---|---|---|
409 | MAX_SLIPPAGE | The estimate is above the max. The body carries estSlippagePct and maxSlippagePct, and message reads Order rejected: slippage 8.32% exceeds your 8% max. |
409 | INSUFFICIENT_LIQUIDITY | The book cannot fill the size. estSlippagePct is null. |
400 | BAD_MAX_SLIPPAGE | maxSlippagePct is outside 0.1 to 30, or has more than one decimal place. |
{ "error": "MAX_SLIPPAGE", "message": "Order rejected: slippage 8.32% exceeds your 8% max.", "estSlippagePct": 8.3214, "maxSlippagePct": 8}
Max slippage decides whether the order may open; an accepted order still fills at the fresh mark. A marketable
limit is bounded by its own price and is not checked, and closes (/sandbox/positions/close) and take-profit
or stop-loss triggers are never limited. If the book cannot be read at that moment, the order proceeds without
the check rather than failing.
What a trade costs
Every fill is charged a fee on notional. Size your trigger levels against it, not against the raw price move.
| Fee | Rate | Applies to |
|---|---|---|
| Taker | 4.5 bps (0.045%) | Market orders, marketable limits, closes, and TP/SL triggers. |
| Maker | 1.5 bps (0.015%) | Resting limit orders that fill. Not reachable in the sandbox, which has no resting limits. |
A market-in, market-out round trip is therefore 9 bps of notional, charged in halves: once when you open and once when you close.
A bracket narrower than the round trip loses money on every outcome
This is the single most expensive mistake an agent author makes, and it looks like a working strategy until you total the ledger. With a 9 bps round trip, a 12 bps take-profit nets +3 bps on a winner while an 8 bps stop loses −17 bps, so a strategy needs to win well over half its trades to break even. Set your bracket wide enough that a winner clears the round trip with room to spare.
Two things to know before you compute your own PnL:
feeon a trade row is the round-trip total, not the leg you paid on this fill. It is the open fee plus the close fee for that position.realizedPnLis gross; the fee is not already deducted from it. Net PnL for a closed trade isrealizedPnL - fee. ReadingrealizedPnLas net, or subtractingfeetwice, are the two independent ways to get this wrong.
Close a position
/sandbox/positions/closeAPI keyClose or partially close at a fresh mark. Omit pct (or pass 100) to fully close; pass 1-99 to partially close.
| Field | Type | Notes |
|---|---|---|
symbol | string | Required. |
pct | number | Optional. 1-100. Defaults to 100. |
curl -X POST https://api.perpsfund.com/v1/sandbox/positions/close \ -H "X-API-Key: $PERPSFUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{"symbol":"BTC","pct":50}'
{ "ok": true, "closed": { "symbol": "BTC", "pct": 50, "exitPrice": 64500, "realizedPnL": 25, "fee": 1.6 }, "position": { "symbol": "BTC", "side": "long", "size": 0.05, "…": "…" }, "account": { "id": "demo", "environment": "sandbox", "…": "…" }}
position is the remaining position after a partial close, or null when fully closed.
Set take-profit and stop-loss
/sandbox/positions/tpslAPI keySet, edit, or clear the trigger levels on an open position. Pass null for a level to clear it. A level you change must sit on the correct side of the current price, not the entry (for a long, the stop below it and the take-profit above it), so a stop at the entry or in profit is valid. A level at or past the current price, or a stop past the take-profit, is rejected with invalid_tpsl, as on live. With no cached price, the changed levels are judged against the entry.
| Field | Type | Notes |
|---|---|---|
symbol | string | Required. |
takeProfitPrice | number | null | Optional. null clears it. |
stopLossPrice | number | null | Optional. null clears it. |
closePct | number | null | Optional. Percent of the position a trigger closes. |
{ "ok": true, "position": { "symbol": "BTC", "side": "long", "takeProfitPrice": 66000, "stopLossPrice": 62000, "…": "…" }}
Triggers fire on touch, not continuously
The sandbox evaluates your trigger levels on requests that touch your account state - GET /sandbox/account and GET /sandbox/positions. There is no background process watching your demo account between your calls.
Two consequences your agent has to plan for:
- A level fires at the mark when you poll, not at the level. If your stop is at 62,000 and the market gapped to 61,400 while your agent slept, the close is filled at 61,400. Read
exitPriceon the resulting trade; never assume it equals the level you set. - A crossing is deferred, never lost. A cross does not un-cross itself, so the trigger is still there on your next poll. But an agent that stops polling stops enforcing its own risk.
Poll on a cadence you would be comfortable with as your worst-case fill delay - a few seconds is normal. Levels are still worth setting: they close the position without a round-trip decision from your agent, and they survive an agent that crashes and restarts.
Reset your sandbox
/sandbox/resetAPI keyWipe your demo account back to a fresh starting state. Useful to start a clean run. Only affects your own sandbox.
{ "ok": true, "account": { "id": "demo", "environment": "sandbox", "balance": 50000, "…": "…" } }The machine-readable spec
An OpenAPI 3.0.3 spec describes the whole v1 surface, with schemas and errors, for code generators, Postman, a Swagger viewer, or an agent that reads it for context. All 31 paths are in it: the sandbox endpoints, /health, /me, and the live routes documented on Live trading.
It needs no key. Fetch it, read it, and decide whether you want one:
curl https://api.perpsfund.com/v1/openapi.json -o perpsfund-openapi.jsonCreating a key still needs an account with API beta access, and every endpoint the spec describes still needs that key. The document is a map, not a door.
Perps Fund sandbox trading endpoints (all POST with a JSON body, header X-API-Key: pk_test_...): Place / add to a position:POST /sandbox/orders{ "symbol": "BTC", "side": "long", "size": 0.1, "leverage": 5, "orderType": "market", "takeProfitPrice": 66000, "stopLossPrice": 62000, "expectedPrice": 64100 }-> { ok, fill:{price,repriced,ageMs}, position, account } Close / partially close:POST /sandbox/positions/close { "symbol": "BTC", "pct": 50 } (omit pct = full close) Change trigger levels on an open position:POST /sandbox/positions/tpsl { "symbol": "BTC", "takeProfitPrice": 66000, "stopLossPrice": null } Notes: market orders fill at a fresh mark; limit orders fill only if immediately marketable else order_not_marketable.No resting limits and no Pro orders here: any orderType other than "limit" (for example "scale") is placed as a MARKET order.Leverage clamps to the market's maxLeverage. The sandbox has NO idempotency: do not blindly retry a write, read/sandbox/positions first. (Live routes accept an Idempotency-Key header; the sandbox ignores it.) ERRORS: an error response has NO "ok" key at all - it is { "error": "<code>", "message": "<text>" } with a 4xx/5xxstatus. Testing 'if response["ok"] is False' therefore NEVER fires. Check the HTTP status, or test for "error". FEES: taker 4.5 bps per leg, so a market-in/market-out ROUND TRIP costs 9 bps of notional.Size take-profit and stop-loss levels WIDER than that or every outcome loses money. On a closed trade row, "fee" isthe ROUND-TRIP total and "realizedPnL" is GROSS - net PnL is realizedPnL - fee (do not subtract the fee twice). CLEARING LEVELS: /sandbox/positions/tpsl treats an explicit null as "clear this level", so send ONLY the levels youmean to change. Sending takeProfitPrice: null while setting a stop wipes the take-profit. TP/SL fire ON TOUCH: levels are evaluated when you call GET /sandbox/account or GET /sandbox/positions, notcontinuously. Polling is what fires your stop. A level fills at the mark at poll time, not at the level, so a gapbetween polls fills worse than the level. A crossing is deferred rather than lost. GET /sandbox/trades reports thecause in orderType: "stop_loss", "take_profit", or "market" (a close you made, a flip, or a drawdown liquidation).