Build an agent
A copy-paste starter agent, a reusable client, the rules that keep it out of trouble, and the mistakes to skip.
An agent is a loop: read state, decide, act, repeat. Here is a minimal one on the sandbox, a reusable client to grow it from, the rules that keep it out of trouble, and the mistakes to skip.
A minimal agent
A complete, runnable skeleton. It resets the account, opens a small position with a stop-loss, then polls until the position closes. Read the comments; swap in your own logic where marked.
import os, time, requests BASE = "https://api.perpsfund.com/v1"KEY = os.environ["PERPSFUND_API_KEY"] # sandbox key, from an env varH = {"X-API-Key": KEY, "Content-Type": "application/json"} def get(path): return requests.get(BASE + path, headers=H).json()def post(path, body=None): return requests.post(BASE + path, headers=H, json=body or {}).json() post("/sandbox/reset") # start from a clean paper accountacct = get("/sandbox/account")["account"]print("equity:", acct["equity"]) price = get("/sandbox/prices?symbol=BTC")["price"]stop = round(price * 0.98, 2) # a 2% protective stop below entry # --- your decision goes here: what, which side, how big ---res = post("/sandbox/orders", { "symbol": "BTC", "side": "long", "size": 0.01, "leverage": 5, "stopLossPrice": stop,})if not res.get("ok"): print("order rejected:", res.get("error"), res.get("reason"))else: print("filled at:", res["fill"]["price"]) # Poll until the position is gone (the stop closed it). Note this read is not only OBSERVING:# trigger levels are evaluated on reads, so polling is what fires your stop. Stop polling and# nothing enforces it until you come back. See "Triggers fire on touch" in Trading.while True: positions = get("/sandbox/positions")["positions"] if not any(p["symbol"] == "BTC" for p in positions): print("position closed") break time.sleep(5) # poll a few seconds apart - reads are a warm cache # why it closed: "stop_loss", "take_profit", or "market" (you, a flip, or a liquidation)last = get("/sandbox/trades")["trades"][0]print("closed by:", last["orderType"], "at", last["exitPrice"], "pnl", last["realizedPnL"])
A reusable client
A small wrapper so the rest of your agent reads cleanly. It raises on any rejection, so a failed call never passes silently.
Read the _request method before you trust it: an error response carries no ok key at all (see Errors), so a client that tests if r.get("ok") is False never fires: None is False is False, and every rejection sails through. Detect the error shape itself, and check the status code. That also keeps the client right on live, where a success carries "success": true rather than ok.
import os, requests _UNSET = object() # so "clear this level" (None) is distinguishable from "leave it alone" class PerpsFundError(RuntimeError): """A rejection from the API. `.code` is the stable machine code, e.g. "bad_symbol".""" def __init__(self, status, code, reason=""): super().__init__(f"{status} {code}{': ' + reason if reason else ''}") self.status, self.code, self.reason = status, code, reason class PerpsFund: def __init__(self, key=None, base="https://api.perpsfund.com/v1"): self.base = base self.h = {"X-API-Key": key or os.environ["PERPSFUND_API_KEY"], "Content-Type": "application/json"} def _request(self, method, path, body=None): r = requests.request(method, self.base + path, headers=self.h, json=body, timeout=15) try: data = r.json() except ValueError: # Not JSON. You are not talking to the API. A proxy, a captive portal or an # edge security challenge is answering instead. Never parse on; say so. raise PerpsFundError(r.status_code, "non_json_response", f"expected JSON, got {r.headers.get('content-type')!r}") if not r.ok: raise PerpsFundError(r.status_code, data.get("error", "unknown"), data.get("reason", "")) if "error" in data: # belt and braces: an error body with a 2xx raise PerpsFundError(r.status_code, data["error"], data.get("reason", "")) return data def _get(self, path): return self._request("GET", path) def _post(self, path, body=None): return self._request("POST", path, body or {}) # reads def account(self): return self._get("/sandbox/account")["account"] def positions(self): return self._get("/sandbox/positions")["positions"] def markets(self): return self._get("/sandbox/markets")["markets"] def price(self, symbol): return self._get(f"/sandbox/prices?symbol={symbol}")["price"] # actions def order(self, symbol, side, size, **opts): return self._post("/sandbox/orders", {"symbol": symbol, "side": side, "size": size, **opts}) def close(self, symbol, pct=100): return self._post("/sandbox/positions/close", {"symbol": symbol, "pct": pct}) def set_tpsl(self, symbol, take_profit=_UNSET, stop_loss=_UNSET): # Only send the levels you named. Sending null CLEARS a level, so passing one and # defaulting the other would silently wipe the one you left out. body = {"symbol": symbol} if take_profit is not _UNSET: body["takeProfitPrice"] = take_profit if stop_loss is not _UNSET: body["stopLossPrice"] = stop_loss return self._post("/sandbox/positions/tpsl", body) def reset(self): return self._post("/sandbox/reset")
Setting one trigger level clears the other
POST /sandbox/positions/tpsl treats an explicit null as clear this level. A client that defaults both parameters to None and always sends both will wipe your take-profit the moment you set a stop. The client above sends only the levels you actually named; if you write your own, do the same. To clear a level deliberately, pass None for it.
Key rules for AI agents
Follow these and most integration bugs never happen:
- Build with a
pk_test_key. It trades the sandbox on/sandbox/*and nothing else. Apk_live_key trades your real accounts on the live routes, so switch only when the agent is ready. - Detect rejections by status or by
error, never byok. An error body has nookkey, so a guard testingok is Falsenever fires and every rejection passes silently. - Price your levels above the fees. A round trip costs 9 bps of notional. A take-profit narrower than that loses money even when you are right.
- Trust the fill, not the read. Price and position reads come from a warm cache and can lag a few seconds. Every order re-fetches the symbol fresh, so the fill price is the authoritative one.
- Poll, do not stream. There is no WebSocket yet. Read the REST endpoints on an interval (a few seconds is plenty); do not hammer them in a tight loop.
- A missing mark is not zero.
markPriceandunrealizedPnLcome backnullwhen the feed did not price a symbol on that read. Skip it and re-read; never treatnullas0. - Do not blindly retry a write. On live, send an
Idempotency-Keyheader with everyPOSTand resend a timed-out request with the same key: nothing is placed twice. The sandbox does not deduplicate yet: ifPOST /sandbox/orderstimes out, read/sandbox/positionsfirst to see whether it applied before you resend, or you may double-place. - Leverage is clamped for you. Ask for more than a market's cap and it is lowered to the cap, not rejected. Read the cap from
/sandbox/markets. - Limit orders must be immediately fillable on the sandbox. A limit price that is not marketable against the current mark returns
order_not_marketable; resting limit orders are not held there. - Send only
marketorlimitasorderTypeon the sandbox. Any other value, including a Pro type such as"scale", is placed as a market order rather than refused. Pro orders exist on the live routes only.
Common pitfalls
The failures we see most, and the fix for each:
| Pitfall | What you see | Fix |
|---|---|---|
Testing if r["ok"] is False | Rejections pass silently; a later KeyError | An error body has no ok key. Check the HTTP status, or test for error. See Errors. |
| A real key on the other environment's route | 403 wrong_environment | pk_test_ keys trade /sandbox/*; pk_live_ keys trade the live routes. |
| A mistyped or invented key | 401 invalid_api_key (not wrong_environment) | The key is resolved before its environment is checked. Recheck the key itself. |
| Header dropped on redirect | 401 missing_api_key | Call the /v1 base directly. Some clients drop X-API-Key when following the /api/v1 redirect. |
| Symbol not tradeable | 400 bad_symbol | Use a symbol from /sandbox/markets. Pass the plain symbol (BTC), not a decorated form. Do not retry it. |
| Retrying a typo'd symbol forever | 400 bad_symbol on a loop | no_price is the transient one; bad_symbol never resolves. Branch on which you got. |
| Order too large for the caps | 400 with the specific cap code (PER_POSITION_CAP, TOTAL_MARGIN_CAP, ASSET_NOTIONAL_CAP, MIN_NOTIONAL, ...); cap_exceeded only as a rare fallback | Lower size or leverage. Branch on the specific codes. |
Treating a null mark as 0 | Nonsense PnL, phantom liquidations | Skip a null markPrice and re-read; the feed self-heals. |
Expecting a symbol in the bulk /prices map | KeyError on a symbol you know exists | Unpriced symbols are absent from that map, not null. Iterate the keys you got. |
| Setting one trigger level | The other level silently cleared | null means "clear". Send only the levels you mean to change. |
| A bracket narrower than the fees | A losing strategy that looks profitable | A round trip costs 9 bps. Size levels wider. See Trading. |
| Blind retry after a timeout | A doubled position | Sandbox: read /sandbox/positions before resending a write, because the sandbox does not deduplicate. Live: resend with the same Idempotency-Key. |
| A Pro payload sent to the sandbox | A market order you did not mean to place | The sandbox treats an unknown orderType as market. Send Pro orders to the live routes only. |
Context for building a Perps Fund sandbox trading agent. BASE URL: https://api.perpsfund.com/v1AUTH: header X-API-Key: pk_test_... on every request except /health READ endpoints (GET):/me -> { userId, environment, keyId }/sandbox/account -> account: balance, equity, unrealizedPnL, profitTarget, dailyLossFloor, globalLossFloor, profitSplit, status, isLocked UNITS: profitTarget / dailyLossFloor / globalLossFloor are ABSOLUTE DOLLAR LEVELS, not deltas and not percentages (breach: equity <= a floor; pass: automatic once equity minus the taker fees to close every position is >= profitTarget, which closes the book for you). profitSplit is a FRACTION (0.8 = 80%), not a percent./sandbox/positions -> positions[]: symbol, side, size, leverage, entryPrice, markPrice(nullable), unrealizedPnL(nullable), takeProfitPrice, stopLossPrice/sandbox/trades -> closed trades: realizedPnL, fee, entry/exit price/sandbox/markets -> markets[]: symbol, label, maxLeverage, markPrice(nullable)/sandbox/prices?symbol=BTC -> { symbol, price } (omit symbol for all marks) WRITE endpoints (POST, JSON body):/sandbox/orders body { symbol, side:"long"|"short", size(base units, >0), leverage?, orderType?:"market"|"limit", limitPrice?, takeProfitPrice?, stopLossPrice?, expectedPrice? }/sandbox/positions/close body { symbol, pct? (1-100, default 100) }/sandbox/positions/tpsl body { symbol, takeProfitPrice?|null, stopLossPrice?|null, closePct? }/sandbox/reset (no body) wipes the paper account to its opening balance RULES:- The sandbox needs a pk_test_ key. Trust the fill price, not the warm read.- Poll on an interval (no WebSocket). A null markPrice means "no mark on this read", not 0.- Leverage is clamped to the market cap server-side. Limit orders must be immediately marketable.- orderType is "market" or "limit" ONLY. Any other value (e.g. "scale") is placed as a MARKET order, not refused.- The sandbox has no idempotency: after a timeout, read /sandbox/positions before retrying a write.- A sandbox with no writes for 30 days is deleted; the next call starts a fresh account. ERRORS: JSON body with a stable "error" code, a "message" sentence and the matching HTTP status, e.g. { "error": "bad_symbol", "message": "Unknown market symbol.", "symbol": "DOGE" }. Branch on error.An error body has NO "ok" key at all. A sandbox success body has "ok": true (a live success has "success": true instead). So a guard written as'if response["ok"] is False' NEVER fires on a rejection - .get("ok") is None, and None is not False.Detect a rejection by the HTTP status, or by testing whether "error" is present. If the body does notparse as JSON at all you are not talking to the API (a proxy or an edge challenge answered) - do not parse on. FEES: taker 4.5 bps per leg = 9 bps per market-in/market-out round trip, on notional.Trigger levels narrower than the round trip lose money on every outcome. On a closed trade, "fee" is theROUND-TRIP total and "realizedPnL" is GROSS: net = realizedPnL - fee. CLEARING LEVELS: on /sandbox/positions/tpsl an explicit null CLEARS that level. Send only the levels you meanto change, or setting a stop will wipe an existing take-profit. Please write clean, well-structured code for this API in my language.