Docs / Reference / Python examples

Python examples

Call the PerkBridge Trading API from Python with the requests library: read your account, get prices, place market and pending orders on MT5 demo accounts, modify, cancel and close them, and follow events.

Updated 26 Oct 2026·Version 0.x
Plain HTTP. There is no official PerkBridge Python client yet. The examples on this page use the requests library and work with any Python 3.10 or later.

This page shows how to call the Trading API from your own Python program. Each example uses plain HTTP with the requests library and Python 3.10 or later. For every field and response, see the Trading API reference.

Important. The Trading API works with MetaTrader 5 demo accounts only. Live and contest accounts are refused with account_not_demo. You can place market, limit, stop and stop limit orders, modify and cancel pending orders, and close a whole position. On MT5, stop loss and take profit are refused on market orders with stops_not_supported; set them on limit and stop orders instead.

1. Before you start

You need an API token for one trading account. Create it in the dashboard:

  • Open API tokens in the sidebar and pick the Trading account.
  • In Create a token, enter a Name and Expires after (days). The default is 90 days, the maximum is 365.
  • Choose Access: Read only (balances, prices, positions, orders and history) or Full (also places orders and closes positions, within the account’s risk limits). The order and close examples below need Full.
  • Choose the Route. Any route: the whole account works for every example here.
  • Click Create token. The dashboard may ask you to confirm your sign-in first.
  • Copy the secret from New token created: copy it now. It is shown only once.

Every token expires. An account can have at most 10 active tokens, and you can create at most 20 tokens per hour. A token sees only its own account.

Install requests and put the base URL and the token in environment variables. The API is served under /api on your dashboard address, so replace https://nile.example.com with your own dashboard address and keep /api.

pip install requests export NILE_BASE_URL=”https://nile.example.com/api” export NILE_API_TOKEN=”paste-your-token-here”
Tip. Never put the token in your source code or in a URL. Keep it in an environment variable or a secret store. If a token leaks, revoke or rotate it on the API tokens page. Revocation takes effect on the next request.

2. A small helper

Save this as nile_api.py. It sends the token in the Authorization: Bearer header, sets a timeout, and turns every error answer into a Python exception with the API’s error code, message and request_id.

# nile_api.py import os import time import uuid import requests BASE_URL = os.environ.get(“NILE_BASE_URL”, “https://nile.example.com/api”).rstrip(“/”) TOKEN = os.environ[“NILE_API_TOKEN”] TIMEOUT = 10 # seconds session = requests.Session() session.headers.update({ “Authorization”: f”Bearer {TOKEN}”, “Accept”: “application/json”, }) class NileAPIError(Exception): “””An error answer from the Trading API.””” def __init__(self, status, code, message, request_id=None, refused_reason=None, order_id=None): super().__init__(f”{status} {code}: {message}”) self.status = status self.code = code self.message = message self.request_id = request_id self.refused_reason = refused_reason # only on order_refused self.order_id = order_id # only on order_refused def request(method, path, *, params=None, json=None, headers=None): “””Send one request and return the response, or raise NileAPIError.””” resp = session.request(method, BASE_URL + path, params=params, json=json, headers=headers, timeout=TIMEOUT) if resp.ok: return resp try: err = resp.json()[“error”] except (ValueError, KeyError, TypeError): raise NileAPIError(resp.status_code, “http_error”, resp.text[:200]) from None raise NileAPIError( resp.status_code, err.get(“code”), err.get(“message”), request_id=err.get(“request_id”), refused_reason=err.get(“refused_reason”), order_id=err.get(“order_id”), ) def call(method, path, **kwargs): “””Send one request and return the JSON body.””” return request(method, path, **kwargs).json() def pages(path, params=None): “””Yield every item of a paged list (accounts, positions, orders, history).””” params = dict(params or {}, limit=200) while True: page = call(“GET”, path, params=params) yield from page[“items”] if not page[“next_cursor”]: return params[“cursor”] = page[“next_cursor”]

Money, prices and volumes are decimal strings such as “0.10” or “1.08345”, never JSON numbers. Use decimal.Decimal if you need to do arithmetic with them, not float.

When you report a problem, include the request_id from the error. It is also in the X-Request-ID response header.

3. Read the account and list positions

A token sees only its own account, so the account list has one item. Keep its account_id for the other calls.

from decimal import Decimal from nile_api import call, pages account = call(“GET”, “/v1/trading/accounts”)[“items”][0] ACCOUNT_ID = account[“account_id”] print(account[“name”], account[“type”], account[“trade_mode”], account[“currency”]) print(“balance:”, account[“balance”], “equity:”, account[“equity”]) print(“margin:”, account[“margin”], “free margin:”, account[“free_margin”]) if not account[“ready”]: # For example: trading is off, the terminal is not connected, or a halt is on. print(“not ready:”, account[“not_ready_reason”]) # Read one account directly once you know its ID. account = call(“GET”, f”/v1/trading/accounts/{ACCOUNT_ID}”) # Open positions, as last reported by the broker. for p in pages(f”/v1/trading/accounts/{ACCOUNT_ID}/positions”): profit = Decimal(p[“profit”]) if p[“profit”] is not None else None print(p[“position_id”], p[“symbol”], p[“side”], p[“volume”], “open”, p[“open_price”], “now”, p[“current_price”], “profit”, profit)

The positions answer also has stale. It is true when the broker has not reported recently, so treat the list with care until it is false again. Positions opened by hand or by another EA appear too, with origin set to external.

4. Get a quote and recent bars

You can only get prices for, and trade, the symbols on the account’s list. Check the list first: it tells you the volume limits, and which order types and times in force the broker accepts for each symbol.

from nile_api import call ACCOUNT_ID = “tac-…” # from step 3 SYMBOL = “EURUSD” symbols = call(“GET”, f”/v1/trading/accounts/{ACCOUNT_ID}/symbols”)[“items”] spec = next(s for s in symbols if s[“symbol”] == SYMBOL) print(“volume min/step/max:”, spec[“volume_min”], spec[“volume_step”], spec[“volume_max”], spec[“volume_unit”]) print(“order types:”, spec[“order_types”]) # for example [“market”, “limit”, “stop”, “stop_limit”] print(“time in force:”, spec[“time_in_force”]) # for example [“gtc”, “day”, “gtd”, “gtd_day”] # Latest bid and ask. Pass several symbols separated by commas. quote = call(“GET”, f”/v1/trading/accounts/{ACCOUNT_ID}/quotes”, params={“symbols”: SYMBOL})[“items”][0] print(quote[“symbol”], “bid”, quote[“bid”], “ask”, quote[“ask”], “spread”, quote[“spread_points”], “age ms”, quote[“age_ms”], “fresh”, quote[“fresh”]) # The last 50 completed 5-minute bars (bid prices), oldest first. bars = call(“GET”, f”/v1/trading/accounts/{ACCOUNT_ID}/bars”, params={“symbol”: SYMBOL, “timeframe”: “M5”, “limit”: 50}) for bar in bars[“items”][-5:]: print(bar[“time”], bar[“open”], bar[“high”], bar[“low”], bar[“close”], bar[“tick_volume”])

Timeframes are M1, M5, M15, M30, H1, H4 and D1. You can ask for up to 1000 bars, and use from and to (ISO 8601 times) for a range. Bars are read from the terminal when you ask. If its history is not synchronized yet you get 503; wait a moment and try again.

A quote with fresh set to false is too old or too wide to trade under the account’s limits. An order sent then is refused.

5. Place a market order

Every order carries your own client_order_id. Use a new uuid4 for each new order. You can also send it in the Idempotency-Key header; when you do, it must be the same value.

import time import uuid import requests from nile_api import NileAPIError, call, request ACCOUNT_ID = “tac-…” # from step 3 def place_market_order(symbol, side, volume, client_order_id=None, attempts=4): “””Place a market order. Safe to retry: the same ID never places a second order.””” client_order_id = client_order_id or str(uuid.uuid4()) body = { “client_order_id”: client_order_id, “type”: “market”, “symbol”: symbol, “side”: side, # “buy” or “sell” “volume”: volume, # a string, a multiple of the symbol’s volume_step “comment”: “python example”, } for attempt in range(attempts): try: resp = request(“POST”, f”/v1/trading/accounts/{ACCOUNT_ID}/orders”, json=body, headers={“Idempotency-Key”: client_order_id}) replayed = resp.headers.get(“Idempotent-Replayed”) == “true” return resp.json(), replayed except (requests.ConnectionError, requests.Timeout): pass # no answer: the order may or may not be stored, so retry with the same ID except NileAPIError as e: if e.status not in (429, 503): raise if attempt + 1 < attempts: time.sleep(2 ** attempt) raise RuntimeError(f”no answer for order {client_order_id}; retry later with the same ID”) order, replayed = place_market_order(“EURUSD”, “buy”, “0.10”) print(order[“order_id”], order[“status”], “replay” if replayed else “new”)

A 202 answer means the order passed every check and is queued. It is not executed yet. A 200 answer with the Idempotent-Replayed: true header means the API already had this client_order_id and returns the stored order instead of placing a new one.

Tip. Retry a lost or timed-out request with the same client_order_id and the same body. That is always safe. Use a new ID only when you want a new order, for example after a refusal or an expired order. Sending the same ID with a different body is refused with idempotency_key_reused.

Now follow the order until its status is final. Poll the order, then read its timeline to see each step.

import time from nile_api import call ACCOUNT_ID = “tac-…” # from step 3 def wait_for_order(order_id, timeout_s=30): “””Poll an order until its status is final, or until the timeout.””” deadline = time.monotonic() + timeout_s while True: order = call(“GET”, f”/v1/trading/accounts/{ACCOUNT_ID}/orders/{order_id}”) if order[“final”] or time.monotonic() > deadline: return order time.sleep(1) order = wait_for_order(order[“order_id”]) # order from place_market_order() print(“status:”, order[“status”]) if order[“status”] in (“filled”, “partially_filled”): print(“filled”, order[“filled_volume”], “at”, order[“average_price”]) elif order[“status”] == “refused”: print(“refused:”, order[“refused_reason”][“code”], order[“refused_reason”][“message”]) # Every step, in order: queued, sending, sent_to_broker, filled or rejected … timeline = call(“GET”, f”/v1/trading/accounts/{ACCOUNT_ID}/orders/{order[‘order_id’]}/timeline”) for step in timeline[“items”]: print(step[“seq”], step[“at”], step[“step”], step[“source”], step.get(“broker_code”, “”), step.get(“message”, “”))

What the order status tells you:

STATUSMEANINGWHAT TO DO
queued, sendingPassed the checks, on its way to the broker.Keep polling.
filled, partially_filledExecuted. See filled_volume and average_price.Find the new position in the positions list (its order_id matches).
refusedA check refused it. It was never sent. See refused_reason.Fix the cause, then place a new order with a new ID.
rejectedThe broker rejected it. The timeline step has the broker’s broker_code.Check the code, then decide whether to place a new order.
expiredIt was not sent in time (by default within 10 seconds).Place a new order with a new ID if you still want it.
checkingThe outcome was unclear and is being checked with the broker. It is never sent twice.Wait. Do not place the order again.
not_executedThe broker confirmed it has no such order.Place a new order with a new ID if you still want it.
unknownChecking gave up. An operator resolves it.Do not place it again. Ask your administrator.
placedA pending order rests at the broker. filled_volume and remaining_volume show a partial fill.Keep polling, or modify or cancel it (step 9).
cancelledA pending order left the broker with nothing filled. See cancel_reason.Place a new order if you still want it.
doneA modify or cancel was applied at the broker.Read the order it changed.
Important. Do not loop on refused orders. An account (or route) turns trading off by itself after repeated refusals within a minute, to stop a runaway program. Turning it back on is done in the dashboard.

6. Close a position

A close is a market order that closes the whole position. Partial close is not available yet. Closing is allowed even when trading is turned off, during an emergency halt, or after a risk limit is hit. You need a Full token.

import uuid from nile_api import call, request ACCOUNT_ID = “tac-…” # from step 3 def close_position(position_id, client_order_id=None): “””Close a whole position. Retry with the same client_order_id if the answer is lost.””” client_order_id = client_order_id or str(uuid.uuid4()) resp = request( “POST”, f”/v1/trading/accounts/{ACCOUNT_ID}/positions/{position_id}/close”, json={“client_order_id”: client_order_id, “comment”: “python example”}, headers={“Idempotency-Key”: client_order_id}, ) return resp.json() # the close order, with kind “close” positions = call(“GET”, f”/v1/trading/accounts/{ACCOUNT_ID}/positions”)[“items”] for p in positions: if p[“symbol”] == “EURUSD”: close_order = close_position(p[“position_id”]) print(“close order”, close_order[“order_id”], close_order[“status”]) # Follow it like any other order: wait_for_order(close_order[“order_id”])

The answer is the close order, queued. Follow it with wait_for_order() from step 5. The retry rules are the same as for placing an order.

7. Follow events

The account has an event feed that you poll with a cursor. There is no WebSocket or server-sent events stream. The first call without a cursor starts the feed now, with no history, so read the state you need first (account, positions, orders), then follow the feed.

import time from nile_api import NileAPIError, call ACCOUNT_ID = “tac-…” # from step 3 def follow_events(handle, resync): “””Poll the event feed forever and pass each event to handle().””” resync() cursor = None while True: params = {“limit”: 200} if cursor: params[“after”] = cursor try: page = call(“GET”, f”/v1/trading/accounts/{ACCOUNT_ID}/events”, params=params) except NileAPIError as e: if e.code == “cursor_expired”: resync() # read the state endpoints again cursor = None # and restart the feed from now continue raise for event in page[“items”]: handle(event) cursor = page[“next_cursor”] if not page[“items”]: time.sleep(1) def handle(event): kind = event[“type”] data = event[“data”] if kind == “order.updated”: order, step = data[“order”], data[“step”] print(“order”, order[“order_id”], order[“status”], “step”, step[“step”]) elif kind == “positions.updated”: print(“positions as of”, data[“as_of”], “:”, len(data[“positions”]), “open”) elif kind in (“account.updated”, “route.updated”, “halt.updated”): print(kind, event[“occurred_at”]) # Ignore event types you do not know; new ones may be added. def resync(): account = call(“GET”, f”/v1/trading/accounts/{ACCOUNT_ID}”) print(“trading enabled:”, account[“trading_enabled”], “halted:”, account[“halted”]) follow_events(handle, resync)

Event types are order.updated, positions.updated, account.updated, route.updated and halt.updated. Money changes on the account do not emit events, so read the account when you need the current balance and equity. If you stop polling for too long, your cursor expires and the API answers 410 cursor_expired; the example above handles that.

8. Handling errors

Every error has the same shape: {“error”: {“code”, “message”, “request_id”}}. A refused order also has order_id and refused_reason. The helper in step 2 puts all of these on NileAPIError.

HTTPCODEMEANINGWHAT TO DO
400invalid_requestThe request is not valid, for example a volume as a number instead of a string. Nothing was stored.Fix the request.
401unauthenticatedThe token is missing, wrong, expired or revoked. All of these give the same answer.Check the environment variable. Create or rotate the token in the dashboard.
403forbiddenThe token’s access does not allow this, for example a Read only token placing an order.Use a Full token.
404not_foundThe ID does not exist, or it belongs to another account than the token’s.Check the IDs.
409invalid_stateFor bars: the account is not bound to the terminal’s current session, or the terminal refused.Check the deployment in the dashboard, then try again.
410cursor_expiredThe event cursor is too old.Read the state again and restart the feed without a cursor.
422order_refusedA check refused the order. It is stored and never sent. See refused_reason below.Fix the cause, then use a new client_order_id.
422idempotency_key_reusedThe client_order_id was already used for a different order.Use a new ID for a new order.
429rate_limitedToo many requests for this token (10 per second, bursts up to 20).Slow down and retry after a short wait.
503recovery_in_progress, unavailableThe service is still checking orders after a restart, or the terminal did not answer.Retry later. For orders, retry with the same ID.

A check can refuse an order in two places: right away (a 422 order_refused answer) or after it was queued (the order’s status becomes refused). Either way, refused_reason.code tells you why. Common reasons:

REASON CODEMEANINGWHAT TO DO
account_not_demoThe MT5 account is a live or contest account.Use a demo account. Live trading is not supported.
stops_not_supportedYou sent stop_loss or take_profit on an MT5 market order, or the broker does not accept them on this symbol.Leave both out of market orders, or use a limit or stop order.
order_type_not_supported, time_in_force_not_supportedThe broker does not accept this order type or time in force on the symbol.Check the symbol’s order_types and time_in_force.
stops_invalid, price_invalid, expiration_invalidA price is on the wrong side of the market, too close to it, not a multiple of the tick size, or an expiry is too near.Fix the price or expiry and use a new ID.
order_not_pendingA modify or cancel named an order that no longer rests at the broker.Read the order; it may have filled or been cancelled.
risk_outside_trading_hours, risk_market_closing, risk_news_windowA time rule blocks new positions right now.Wait for the window to end. Closes and cancels still work.
risk_rule_trippedA risk rule on your server blocks new positions. It stays locked until its period ends.See Risk rules. You can still close positions.
risk_policy_pendingThe account’s risk rules are not active on the server yet.Wait and try again with a new ID.
exposure_limit, daily_loss_limitThe order would go past the account’s volume, position or loss limits.Reduce the volume or wait. See Risk rules.
trading_disabled, haltedTrading is off for the account, or an emergency halt is on.Ask whoever runs the dashboard. Closes still work.
quote_staleThe latest price is too old to trade.Check the quote’s fresh flag and try again later.
in_flight_limitAnother order on the account has not finished yet.Wait until the previous order is final.
rate_limitedThe account’s own order rate limit (a refusal, not the 429 above).Place fewer orders per minute.

Your code must accept reason codes it does not know; new ones can be added. Log the code and message, and treat it as a refusal. For other problems, see Troubleshooting.

9. Place, modify and cancel a pending order

A limit, stop or stop_limit order rests at the broker until the price reaches it, its time in force ends or you cancel it. While it rests, its status is placed. Stop loss and take profit are accepted on these orders.

import uuid from nile_api import request ACCOUNT_ID = “tac-…” # from step 3 def post(path, body): cid = body[“client_order_id”] return request(“POST”, f”/v1/trading/accounts/{ACCOUNT_ID}{path}”, json=body, headers={“Idempotency-Key”: cid}).json() # A buy limit below the market, good until the end of the trade server’s day. order = post(“/orders”, { “client_order_id”: str(uuid.uuid4()), “type”: “limit”, “symbol”: “EURUSD”, “side”: “buy”, “volume”: “0.10”, “price”: “1.07900”, “stop_loss”: “1.07500”, “take_profit”: “1.08600”, “time_in_force”: “day”, }) # Move the price and remove the take profit. A field left out keeps its value. modify = post(f”/orders/{order[‘order_id’]}/modify”, { “client_order_id”: str(uuid.uuid4()), “price”: “1.07850”, “take_profit”: None, }) # Cancel it. A cancel works during a halt and with trading turned off. cancel = post(f”/orders/{order[‘order_id’]}/cancel”, { “client_order_id”: str(uuid.uuid4()), })
  • Time in force: gtc (until cancelled, the default), day, gtd with good_until (an ISO 8601 time at least 60 seconds ahead) or gtd_day with good_until_date (a trade server date). The symbol’s time_in_force list must include it.
  • Stop limit: price is the trigger and stop_limit_price the limit placed once the trigger is reached.
  • Modify and cancel are orders of their own, with their own client_order_id. Each ends done once the broker shows the change. A modify cannot change the volume: cancel and place a new order instead.
  • Only orders PerkBridge placed can be modified or cancelled. A route token changes only its route’s orders.
  • When the broker removes an expired order, the order ends cancelled with cancel_reason broker_expired.

10. Python client

There is no official PerkBridge Python client library yet. The helper in step 2 covers what most programs need: the token header, timeouts, error handling and paging. Keep the rules from this page in any wrapper you write:

  • retry a lost request with the same client_order_id, so a retry never places a second order;
  • treat money, prices and volumes as decimal strings;
  • accept error, reason and event codes you do not know.
Was this page helpful?