Skip to content

Getting started

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. A pk_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 by ok. An error body has no ok key, so a guard testing ok is False never 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. markPrice and unrealizedPnL come back null when the feed did not price a symbol on that read. Skip it and re-read; never treat null as 0.
  • Do not blindly retry a write. On live, send an Idempotency-Key header with every POST and resend a timed-out request with the same key: nothing is placed twice. The sandbox does not deduplicate yet: if POST /sandbox/orders times out, read /sandbox/positions first 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 market or limit as orderType on 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:

PitfallWhat you seeFix
Testing if r["ok"] is FalseRejections pass silently; a later KeyErrorAn error body has no ok key. Check the HTTP status, or test for error. See Errors.
A real key on the other environment's route403 wrong_environmentpk_test_ keys trade /sandbox/*; pk_live_ keys trade the live routes.
A mistyped or invented key401 invalid_api_key (not wrong_environment)The key is resolved before its environment is checked. Recheck the key itself.
Header dropped on redirect401 missing_api_keyCall the /v1 base directly. Some clients drop X-API-Key when following the /api/v1 redirect.
Symbol not tradeable400 bad_symbolUse a symbol from /sandbox/markets. Pass the plain symbol (BTC), not a decorated form. Do not retry it.
Retrying a typo'd symbol forever400 bad_symbol on a loopno_price is the transient one; bad_symbol never resolves. Branch on which you got.
Order too large for the caps400 with the specific cap code (PER_POSITION_CAP, TOTAL_MARGIN_CAP, ASSET_NOTIONAL_CAP, MIN_NOTIONAL, ...); cap_exceeded only as a rare fallbackLower size or leverage. Branch on the specific codes.
Treating a null mark as 0Nonsense PnL, phantom liquidationsSkip a null markPrice and re-read; the feed self-heals.
Expecting a symbol in the bulk /prices mapKeyError on a symbol you know existsUnpriced symbols are absent from that map, not null. Iterate the keys you got.
Setting one trigger levelThe other level silently clearednull means "clear". Send only the levels you mean to change.
A bracket narrower than the feesA losing strategy that looks profitableA round trip costs 9 bps. Size levels wider. See Trading.
Blind retry after a timeoutA doubled positionSandbox: 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 sandboxA market order you did not mean to placeThe sandbox treats an unknown orderType as market. Send Pro orders to the live routes only.
Give this to your AI
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.