Lesson 4.4.3 · 75 min
Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
Four brokers with four ways of receiving an order and of reporting what happens next. This lesson reproduces their documented messages in a local simulator: the same purchase of 434 shares reported by Alpaca and by Tradier, the OANDA transaction stream with a gap that has to be filled and the polling that MetaTrader 5 forces on you.
- Module
- 4.4 · Brokers and APIs
- Duration
- 75 min
- Prerequisites
- 1.3.6 · Brokers as market data sources, 1.4.1 · Downloading over REST and websockets: pagination, rate limits and retries, 3.3.3 · Currency risk in multi-currency portfolios; execution via OANDA, IBKR or MT5, 3.7.4 · ccxt for trading on multiple exchanges
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code
Four brokers, four architectures
Lesson 4.4.2 covered Interactive Brokers, which goes through TWS or IB Gateway. This one covers four others that are widely used from Python, each with a different architecture. Lesson 1.3.6 used them as data sources and lesson 3.3.3 sent currency orders to OANDA and MetaTrader 5 with retries and return codes; here we go to what you need to run a system: paper and live environments, the stream that reports what happened to each order, their format differences and how not to lose messages.
Mermaid source
flowchart TB S[System] --> A[Alpaca REST and WS] S --> T[Tradier REST and WS] S --> O[OANDA REST and HTTP] S --> M[MT5 package] M --> MT[Windows terminal]
| Alpaca | Tradier | OANDA v20 | MetaTrader 5 | |
|---|---|---|---|---|
| Transport | REST JSON; websocket for trade_updates | REST with forms; JSON response if requested (XML by default); account websocket (beta) | REST JSON; streams over chunked HTTP | Calls to a local terminal through interprocess communication |
| Authentication | APCA-API-KEY-ID and APCA-API-SECRET-KEY headers | Authorization: Bearer | Authorization: Bearer | Login, password and server in initialize |
| Paper and live | paper-api.alpaca.markets and api.alpaca.markets, with different keys | sandbox.tradier.com and api.tradier.com; the sandbox has data delayed 15 minutes | api-fxpractice.oanda.com and api-fxtrade.oanda.com; streams on stream-… | The broker's demo or live account, in the same terminal |
| Numbers | Text ("434", "234.8353") | JSON numbers | Text | Python float |
| Own identifier | client_order_id (up to 128 characters) | tag | clientExtensions.id | magic and comment |
The sources for each row are in each broker's section1Note 1 · Alpaca, Create an Order (CreateOrderRequest and Order schemas), Get Order by Client Order ID, Authentication, Paper Trading and WebSocket Streaming; alpaca-py 0.44.0 on PyPI. Accessed on October 22, 2026.2Note 2 · Tradier, Endpoints, Place Order, Get an order, Error responses, Response format, Account streaming responses, Account WebSocket streaming, FAQ and Rate limiting. Accessed on October 22, 2026.3Note 3 · OANDA, Order Definitions, Transaction Definitions, Transaction Endpoints (stream and sinceid) and Development Guide (URLs and limits). Accessed on October 22, 2026.4Note 4 · MetaTrader5 on PyPI (version 5.0.6231, win_amd64 wheels only); MQL5, MetaTrader for Python, initialize, order_send, order_check, copy_ticks_from, trade operation types and return codes. Accessed on October 22, 2026.. The first three are network APIs: the code can
run on any operating system. MetaTrader 5 is not: the Python package talks to a Windows program that has to be
installed and open.
Paper and live: the same API, another URL and other keys
At Alpaca, the authentication documentation is explicit: paper and live accounts have separate credentials and “you cannot use the live account credentials with the paper API, or vice versa”1. That the wrong key gives a 401 error is a protection, but not the only one you need: the dangerous mistake is to have both keys configured and point at the live URL believing you are on paper. The first cell chooses the URL from the mode and requires explicit confirmation for live mode, with the same rule as lesson 0.2.4 for reading a boolean from the environment:
import os
URLS = {
"alpaca": {"papel": "https://paper-api.alpaca.markets",
"real": "https://api.alpaca.markets"},
"tradier": {"papel": "https://sandbox.tradier.com/v1",
"real": "https://api.tradier.com/v1"},
"oanda": {"papel": "https://api-fxpractice.oanda.com",
"real": "https://api-fxtrade.oanda.com"},
}
class ModoNoPermitido(RuntimeError):
pass
def url_base(broker, modo):
if modo == "real" and os.environ.get("PPT_REAL") != "si":
raise ModoNoPermitido(
f"{broker}: live mode without PPT_REAL=si in the environment")
return URLS[broker][modo]
for b in URLS:
print(f"{b:<8} paper -> {url_base(b, 'papel')}")
try:
url_base("alpaca", "real")
except ModoNoPermitido as e:
print("blocked:", e)alpaca paper -> https://paper-api.alpaca.markets
tradier paper -> https://sandbox.tradier.com/v1
oanda paper -> https://api-fxpractice.oanda.com
blocked: alpaca: live mode without PPT_REAL=si in the environmentBy default, everything goes to paper; to trade live you have to say so in the execution environment, not in the code. The keys for each mode are stored separately and with different permissions (lesson 4.4.5).
Alpaca: orders over REST and executions over websocket
An order at Alpaca is a POST /v2/orders with a JSON body1. According to the CreateOrderRequest schema in the
documentation:
typecan bemarket,limit,stop,stop_limitortrailing_stop(for stocks; for crypto, onlymarket,limitandstop_limit), andtime_in_force,day,gtc,opg(open),cls(close),iocorfok.qtyandnotional(amount in dollars, only with market orders andday) are mutually exclusive. Quantities and prices are sent and received as text.client_order_idis your own identifier, up to 128 characters; if you do not send one, Alpaca generates it. The order is then queried by it atGET /v2/orders:by_client_order_id?client_order_id=….order_classallowsbracket,ocoandotoorders withtake_profitandstop_loss.- The error responses documented for the
POSTare 403 (“Buying power or shares is not sufficient”) and 422 (“Input parameters are not recognized”).
With the official SDK, alpaca-py (version 0.44.0 on PyPI, from August 2026), sending and listening to the stream look
like this; it is not in the course environment and needs an account, so it is not executed:
import os
from alpaca.trading.client import TradingClient
from alpaca.trading.enums import OrderSide, TimeInForce
from alpaca.trading.requests import MarketOrderRequest
from alpaca.trading.stream import TradingStream
clave = os.environ["APCA_API_KEY_ID"]
secreto = os.environ["APCA_API_SECRET_KEY"]
cliente = TradingClient(clave, secreto, paper=True)
orden = cliente.submit_order(MarketOrderRequest(
symbol="SPY", qty=434, side=OrderSide.BUY,
time_in_force=TimeInForce.DAY, client_order_id="R00005"))
print(orden.status, orden.client_order_id)
async def al_recibir(evento): # the handler is async
print(evento.event, evento.order.filled_qty, evento.price)
stream = TradingStream(clave, secreto, paper=True)
stream.subscribe_trade_updates(al_recibir)
stream.run() # blocks: its own loopThe trade_updates stream lives at wss://paper-api.alpaca.markets/stream (paper) or wss://api.alpaca.markets/stream
(live). The streaming documentation describes the protocol: a message {"action": "auth", "key": …, "secret": …} that
is answered with {"stream": "authorization", "data": {"status": "authorized", …}}, another
{"action": "listen", "data": {"streams": ["trade_updates"]}} and, from then on, one message per event with the
event field and the full order in order. The usual events are new, fill, partial_fill, canceled,
expired, done_for_day and replaced; executions also carry price, qty, position_qty and timestamp, all as
text. Two details from the documentation: the paper stream uses binary frames, not text frames, and the SDK
authenticates with another form of message ("action": "authenticate" with key_id and secret_key). The second is
what the SDK code does; the documentation only shows the first form.
The course example, against the documented schema
The level 4 file brokers4_ejemplos.json reports an order from the live system in the data (R00005, a market buy of
434 ORSC with three executions) in the shape of each broker's messages. It was written as an example and has to be
checked against the documentation before use. The cell compares its fields with the list of properties of Alpaca's
Order schema, copied from the POST /v2/orders documentation:
import json
from pathlib import Path
EJ = json.loads(Path("brokers4_ejemplos.json").read_text())
ORDER_DOC = set("""id client_order_id created_at updated_at
submitted_at filled_at expired_at expires_at canceled_at failed_at
replaced_at replaced_by replaces asset_id symbol asset_class notional
qty filled_qty filled_avg_price order_class order_type type side
position_intent time_in_force limit_price stop_price status
extended_hours legs trail_percent trail_price hwm ratio_qty group_id
group_role""".split())
final = EJ["alpaca"]["orden_final"]
print("in the example and not in the schema:", set(final) - ORDER_DOC)
print("in the schema and not in the example:",
sorted(ORDER_DOC - set(final)))
tipos = {type(final[k]).__name__ for k in
("qty", "filled_qty", "filled_avg_price")}
print("type of qty, filled_qty and filled_avg_price:", tipos)
for m in EJ["alpaca"]["trade_updates"]:
d = m["data"]
print(f"{d['event']:<13}", {k: d[k] for k in
("qty", "price", "position_qty") if k in d})in the example and not in the schema: set()
in the schema and not in the example: ['expires_at', 'group_id', 'group_role', 'ratio_qty']
type of qty, filled_qty and filled_avg_price: {'str'}
new {}
partial_fill {'qty': '27', 'price': '234.64', 'position_qty': '27'}
partial_fill {'qty': '389', 'price': '234.85', 'position_qty': '416'}
fill {'qty': '18', 'price': '234.81', 'position_qty': '434'}All the example's fields exist in the schema and the numbers go as text, as in the documentation. Four optional fields
are missing (from orders with an expiration and multi-leg orders), which are not used here. One of those that are
present, order_type, is marked by the schema as deprecated in favor of type: new code should read type.
A simulated broker with two faces
This lesson's simulator has a core (orders, executions, subscribers) and two facades that speak like Alpaca and like
Tradier. It runs in the lesson's own event loop, with aiohttp (it comes into the course environment as a dependency
of ccxt) for the HTTP server and the websockets, on a port chosen by the operating system. So that the lesson always
gives the same result, whatever the load on the machine, there is no real clock: the time of each event comes from the
order's script, and the executions of R00005 are those in the example file.
import asyncio
from dataclasses import dataclass, field, replace
@dataclass
class Orden:
id: str
cid: str
simbolo: str
lado: str
cantidad: int
tipo: str
tif: str
creada: str
enviada: str
cuenta: str
estado: str = "new"
ejecutada: int = 0
efectivo: float = 0.0
ultima: str = ""
ejecuciones: list = field(default_factory=list)
@property
def media(self):
if not self.ejecutada:
return None
return self.efectivo / self.ejecutada
class Nucleo:
"""Orders and executions; publishes each event to subscribers.
No clock: times come from each order's script."""
def __init__(self, poder_compra, guiones):
self.poder_compra = poder_compra
self.guiones = guiones # cid -> (created, submitted, executions)
self.ordenes, self.por_cid = {}, {}
self.subs = {} # account -> asyncio queues
self.posicion = {} # (account, symbol) -> shares
def suscribir(self, cuenta):
cola = asyncio.Queue()
self.subs.setdefault(cuenta, []).append(cola)
return cola
async def cerrar(self):
"""Tells subscribers that the server is shutting down."""
for colas in self.subs.values():
for cola in colas:
await cola.put(None)
async def publicar(self, tipo, o, **extra):
foto = replace(o, ejecuciones=list(o.ejecuciones)) # copy
for cola in self.subs.get(o.cuenta, []):
await cola.put((tipo, foto, extra))
def alta(self, cuenta, oid, cid, simbolo, lado, cantidad, tipo,
tif):
creada, enviada, _ = self.guiones[cid]
o = Orden(oid, cid, simbolo, lado, cantidad, tipo, tif, creada,
enviada, cuenta)
self.ordenes[oid], self.por_cid[cid] = o, oid
return o
async def ejecutar(self, o):
_, _, ejec = self.guiones[o.cid]
await self.publicar("new", o, ts=o.enviada)
for ts, q, px, eid in ejec:
o.ejecutada += q
o.efectivo += q * px
o.ultima = ts
signo = 1 if o.lado == "buy" else -1
k = (o.cuenta, o.simbolo)
self.posicion[k] = self.posicion.get(k, 0) + signo * q
o.ejecuciones.append((ts, q, px, eid))
o.estado = ("filled" if o.ejecutada == o.cantidad
else "partially_filled")
tipo = "fill" if o.estado == "filled" else "partial_fill"
await self.publicar(tipo, o, ts=ts, qty=q, price=px,
eid=eid)
R5 = EJ["alpaca"]["trade_updates"]
guion_r5 = (R5[0]["data"]["order"]["created_at"],
R5[0]["data"]["order"]["submitted_at"],
[(m["data"]["timestamp"], int(m["data"]["qty"]),
float(m["data"]["price"]), m["data"]["execution_id"])
for m in R5[1:]])
nucleo = Nucleo(poder_compra=1_000_000.0,
guiones={"R00005": guion_r5})
print("created and submitted:", guion_r5[0][11:], guion_r5[1][11:])
print("executions in the script:", [(q, px) for _, q, px, _ in
guion_r5[2]])created and submitted: 13:35:00.805771Z 13:35:00.835677Z
executions in the script: [(27, 234.64), (389, 234.85), (18, 234.81)]The Alpaca facade translates the core into its shape: it validates the POST body against the schema's lists of
values, checks the headers, replies with the order and publishes each event on the websocket with the full order
inside. Two decisions are the simulator's, not the documentation's: the numeric error code of the 403 (the
documentation gives the text, not the code) and rejecting a repeated client_order_id with a 422. The documentation
says the identifier is unique; exactly what it returns for a repeated one has to be checked on the paper account.
from aiohttp import web, WSMsgType
CLAVES_ALPACA = {"PK-FALSA-PAPEL": "FALSA-SECRETO-PAPEL"}
TIPOS = {"market", "limit", "stop", "stop_limit", "trailing_stop"}
TIFS = {"day", "gtc", "opg", "cls", "ioc", "fok"}
PRECIO_REF = {"ORSC": 234.80} # for buying power
def orden_alpaca(o):
texto = lambda x: None if x is None else f"{x:.4f}"
return {"id": o.id, "client_order_id": o.cid,
"created_at": o.creada,
"updated_at": o.ultima or o.enviada,
"submitted_at": o.enviada,
"filled_at": o.ultima if o.estado == "filled" else None,
"symbol": o.simbolo, "qty": str(o.cantidad),
"filled_qty": str(o.ejecutada),
"filled_avg_price": texto(o.media), "type": o.tipo,
"side": o.lado, "time_in_force": o.tif,
"status": o.estado}
def error(http, codigo, mensaje):
return web.json_response({"code": codigo, "message": mensaje},
status=http)
async def alpaca_post(req):
if CLAVES_ALPACA.get(req.headers.get("APCA-API-KEY-ID")) != \
req.headers.get("APCA-API-SECRET-KEY"):
return error(401, 40110000, "request is not authorized")
d = await req.json()
if d.get("type") not in TIPOS or d.get("time_in_force") not in TIFS:
return error(422, 42210000, "invalid type or time_in_force")
if d.get("client_order_id") in nucleo.por_cid:
return error(422, 42210000, "client_order_id must be unique")
qty = int(d["qty"])
if qty * PRECIO_REF[d["symbol"]] > nucleo.poder_compra:
return error(403, 40310000, "insufficient buying power")
o = nucleo.alta("alpaca", "6f1c2a9e-4b7d-4c1a-9e55-000000039595",
d["client_order_id"], d["symbol"], d["side"], qty,
d["type"], d["time_in_force"])
asyncio.get_running_loop().create_task(nucleo.ejecutar(o))
return web.json_response(orden_alpaca(o))
async def alpaca_por_cid(req):
oid = nucleo.por_cid.get(req.query["client_order_id"])
if oid is None:
return error(404, 40410000, "order not found")
return web.json_response(orden_alpaca(nucleo.ordenes[oid]))
async def alpaca_stream(req):
ws = web.WebSocketResponse()
await ws.prepare(req)
auth = json.loads((await ws.receive()).data)
ok = CLAVES_ALPACA.get(auth.get("key")) == auth.get("secret")
await ws.send_json({"stream": "authorization", "data": {
"status": "authorized" if ok else "unauthorized",
"action": "authenticate"}})
if not ok:
await ws.close()
return ws
pedido = json.loads((await ws.receive()).data)
cola = nucleo.suscribir("alpaca") # before confirming
await ws.send_json({"stream": "listening",
"data": pedido["data"]})
try:
await emitir_alpaca(ws, cola)
except ConnectionResetError: # the client went away
pass
finally:
nucleo.subs["alpaca"].remove(cola)
return ws
async def emitir_alpaca(ws, cola):
while (evento := await cola.get()) is not None:
tipo, o, x = evento
data = {"event": tipo, "timestamp": x["ts"],
"order": orden_alpaca(o)}
if "qty" in x:
data |= {"execution_id": x["eid"],
"price": f"{x['price']:.2f}", "qty": str(x["qty"]),
"position_qty": str(
nucleo.posicion["alpaca", o.simbolo])}
await ws.send_json({"stream": "trade_updates", "data": data})The client does what it would do with Alpaca: it opens the websocket, authenticates, subscribes and only then sends the order, so as not to miss any event. Then it reads events until a final status and rebuilds the order from what arrives: cumulative quantity, traded value and average price.
from aiohttp import ClientSession
app = web.Application()
app.router.add_post("/v2/orders", alpaca_post)
app.router.add_get("/v2/orders:by_client_order_id", alpaca_por_cid)
app.router.add_get("/stream", alpaca_stream)
corredor = web.AppRunner(app)
await corredor.setup()
sitio = web.TCPSite(corredor, "127.0.0.1", 0)
await sitio.start()
BASE = f"http://127.0.0.1:{sitio._server.sockets[0].getsockname()[1]}"
CAB = {"APCA-API-KEY-ID": "PK-FALSA-PAPEL",
"APCA-API-SECRET-KEY": "FALSA-SECRETO-PAPEL"}
FINALES = {"filled", "canceled", "expired", "rejected"}
sesion = ClientSession()
ws = await sesion.ws_connect(BASE + "/stream")
await ws.send_json({"action": "auth", "key": CAB["APCA-API-KEY-ID"],
"secret": CAB["APCA-API-SECRET-KEY"]})
print((await ws.receive_json())["data"]["status"])
await ws.send_json({"action": "listen",
"data": {"streams": ["trade_updates"]}})
print((await ws.receive_json())["stream"])
cuerpo = {"symbol": "ORSC", "qty": "434", "side": "buy",
"type": "market", "time_in_force": "day",
"client_order_id": "R00005"}
async with sesion.post(BASE + "/v2/orders", json=cuerpo,
headers=CAB) as r:
print("POST:", r.status, (await r.json())["status"])
acum, efectivo, eventos = 0, 0.0, []
while True:
d = (await ws.receive_json())["data"]
if "qty" in d:
acum += int(d["qty"])
efectivo += int(d["qty"]) * float(d["price"])
eventos.append(d)
print(f"{d['timestamp'][11:23]} {d['event']:<13}"
f" filled {acum:>3} average "
f"{efectivo / acum if acum else 0:.4f}"
f" (broker: {d['order']['filled_avg_price']})")
if d["order"]["status"] in FINALES:
breakauthorized
listening
POST: 200 new
13:35:00.835 new filled 0 average 0.0000 (broker: None)
13:35:01.024 partial_fill filled 27 average 234.6400 (broker: 234.6400)
13:35:02.522 partial_fill filled 416 average 234.8364 (broker: 234.8364)
13:35:04.441 fill filled 434 average 234.8353 (broker: 234.8353)The four events are those in the example: new at 13:35:00.835 and three executions of 27, 389 and 18 shares in 3.6
seconds. The average price the client computes from the executions matches at every step the filled_avg_price sent by
the broker, which is the check a system should make: if they do not agree, a message is missing. The figure at the top
draws this order: the cumulative quantity rises in three steps and the final average price, 234.8353, ends up a cent and
a half below the price of the second execution, which is the large one.
The last step is to compare the rebuilt order with the one returned by the query by client_order_id and with the
final order in the example file:
async with sesion.get(BASE + "/v2/orders:by_client_order_id",
params={"client_order_id": "R00005"},
headers=CAB) as r:
consultada = await r.json()
comunes = sorted(set(consultada) & set(final))
distintos = [k for k in comunes if consultada[k] != final[k]]
print(len(comunes), "common fields; different:", distintos)
print("rebuilt:", acum, f"{efectivo / acum:.4f}")
print("queried:", consultada["filled_qty"],
consultada["filled_avg_price"], consultada["status"])14 common fields; different: []
rebuilt: 434 234.8353
queried: 434 234.8353 filledThe 14 fields the simulator produces match those in the example, and the order rebuilt from the stream matches the queried one. In a real system, this query is the one made on reconnecting the websocket: events emitted while it was down are not sent again, and the order's state is recovered over REST (lesson 4.5.2).
The errors the POST returns
Three bad requests: a time_in_force that does not exist (gtd, which does exist at OANDA), an order that exceeds the
buying power and the same client_order_id again.
malas = {
"tif gtd": cuerpo | {"client_order_id": "R9001",
"time_in_force": "gtd"},
"10,000 shares": cuerpo | {"client_order_id": "R9002",
"qty": "10000"},
"R00005 again": cuerpo,
}
for nombre, c in malas.items():
async with sesion.post(BASE + "/v2/orders", json=c,
headers=CAB) as r:
print(f"{nombre:<16} {r.status} {(await r.json())['message']}")tif gtd 422 invalid type or time_in_force
10,000 shares 403 insufficient buying power
R00005 again 422 client_order_id must be uniqueThe 422 and the 403 are definitive errors: retrying the same order gives the same result. With 10,000 shares at about 234.80 USD the order asks for 2.35 million and the account has one million of buying power. The third is the one that protects against duplicate orders after a timeout (lesson 3.7.4): the broker recognizes the identifier and does not create another order.
Tradier: forms, JSON and an account stream
Tradier works in a different way2:
- The order is sent as a form (
application/x-www-form-urlencoded) toPOST /v1/accounts/{account_id}/orders, withclass(equity,option,multileg,combo,oto,oco,otoco),symbol,side(buy,sell,sell_short,buy_to_cover),quantity,type(market,limit,stop,stop_limit),duration(day,gtc,pre,post),price,stopandtag. - The default response is XML; for JSON you have to request it with
Accept: application/json. The format guide warns that a list can arrive as an object or as a list depending on the number of elements. - The
POSTreturns{"order": {"id": …, "status": "ok", …}}. The errors page warns that, when there is an error in the systems behind it, thePOSTmay return a 200: you have to query the order and look at its status and itserrorsproperty. - The query (
GET /v1/accounts/{account_id}/orders/{id}) gives numbers as JSON numbers:quantity,avg_fill_price,exec_quantity,last_fill_price,last_fill_quantity,remaining_quantity. - The account stream opens in two steps:
POST /v1/accounts/events/sessionreturns asessionidthat has to be used within 5 minutes, and onwss://ws.tradier.com/v1/accounts/eventsyou send{"events": ["order"], "sessionid": …, "excludeAccounts": []}. The documentation marks it as beta. - The sandbox (
sandbox.tradier.com) is a paper account with data delayed 15 minutes. On the paper stream the documentation contradicts itself: the FAQ says there is no stream for paper and the account stream page gives asandbox-ws.tradier.comserver. Test it before designing around it. - Limits per minute and per token: 60 order requests per minute in production and in the sandbox, 120 query requests in production and 60 in the sandbox.
import json
import os
from urllib.parse import urlencode
from urllib.request import Request, urlopen
BASE = "https://sandbox.tradier.com/v1" # paper
CAB = {"Authorization": f"Bearer {os.environ['TRADIER_TOKEN']}",
"Accept": "application/json"}
cuenta = os.environ["TRADIER_CUENTA"]
datos = urlencode({"class": "equity", "symbol": "SPY", "side": "buy",
"quantity": 434, "type": "market",
"duration": "day", "tag": "R00005"}).encode()
req = Request(f"{BASE}/accounts/{cuenta}/orders", data=datos,
headers=CAB, method="POST")
with urlopen(req, timeout=10) as r:
oid = json.load(r)["order"]["id"]
with urlopen(Request(f"{BASE}/accounts/{cuenta}/orders/{oid}",
headers=CAB), timeout=10) as r:
print(json.load(r)["order"]["status"])The same order, reported by Tradier
The example file includes the query of R00005 once finished, in Tradier's shape. Its fields are those in the example
of the GET …/orders/{id} documentation, with numbers as numbers. The simulator's second facade uses the same core:
the POST with a form, the query and the account stream. The stream's order events carry the fields from the
account stream responses page: status, avg_fill_price, exec_quantity, last_fill_quantity,
remaining_quantity, transaction_date… and not last_fill_price, which that page does not list (the stream
reference page, which is older, also uses executed_quantity instead of exec_quantity).
TOKEN_TRADIER = "FALSA-TOKEN-SANDBOX"
EJ_T = EJ["tradier"]["order"]
nucleo.guiones["R00006"] = guion_r5 # the same order, another account
IDS_T = {} # core id -> Tradier id
def orden_tradier(o):
ult = o.ejecuciones[-1] if o.ejecuciones else (None, 0, 0.0, None)
return {"id": IDS_T[o.id], "type": o.tipo, "symbol": o.simbolo,
"side": o.lado, "quantity": float(o.cantidad),
"status": o.estado, "duration": o.tif,
"avg_fill_price": round(o.media or 0.0, 4),
"exec_quantity": float(o.ejecutada),
"last_fill_price": ult[2],
"last_fill_quantity": float(ult[1]),
"remaining_quantity": float(o.cantidad - o.ejecutada),
"create_date": o.creada[:23] + "Z",
"transaction_date": (o.ultima or o.creada)[:23] + "Z",
"class": "equity"}
def autorizado(req):
return req.headers.get("Authorization") == f"Bearer {TOKEN_TRADIER}"
async def tradier_post(req):
if not autorizado(req):
return web.Response(status=401, text="Invalid Access Token")
f = await req.post() # a form, not JSON
o = nucleo.alta("tradier", "t-" + f["tag"], f["tag"], f["symbol"],
f["side"], int(f["quantity"]), f["type"],
f["duration"])
IDS_T[o.id] = EJ_T["id"]
asyncio.get_running_loop().create_task(nucleo.ejecutar(o))
return web.json_response({"order": {"id": IDS_T[o.id],
"status": "ok"}})
async def tradier_get(req):
inv = {v: k for k, v in IDS_T.items()}
o = nucleo.ordenes[inv[int(req.match_info["oid"])]]
return web.json_response({"order": orden_tradier(o)})
async def tradier_sesion(req):
return web.json_response({"stream": {
"url": "wss://ws.tradier.com/v1/accounts/events",
"sessionid": "sesion-falsa-1"}})
CAMPOS_EVENTO = ("status", "type", "avg_fill_price", "exec_quantity",
"last_fill_quantity", "remaining_quantity",
"transaction_date", "create_date")
async def tradier_eventos(req):
ws = web.WebSocketResponse()
await ws.prepare(req)
pedido = json.loads((await ws.receive()).data)
if pedido.get("sessionid") != "sesion-falsa-1":
await ws.close()
return ws
cola = nucleo.suscribir("tradier")
await ws.send_json({"event": "heartbeat", "status": "active",
"timestamp": 1760448900})
try:
while (evento := await cola.get()) is not None:
tipo, o, x = evento
t = orden_tradier(o)
if tipo == "new":
t["status"] = "open"
await ws.send_json({"id": t["id"], "event": "order",
**{k: t[k] for k in CAMPOS_EVENTO},
"account": "FALSA-1"})
except ConnectionResetError:
pass
finally:
nucleo.subs["tradier"].remove(cola)
return ws
app_t = web.Application()
app_t.router.add_post("/v1/accounts/{cuenta}/orders", tradier_post)
app_t.router.add_get("/v1/accounts/{cuenta}/orders/{oid}",
tradier_get)
app_t.router.add_post("/v1/accounts/events/session", tradier_sesion)
app_t.router.add_get("/v1/accounts/events", tradier_eventos)
corredor_t = web.AppRunner(app_t)
await corredor_t.setup()
sitio_t = web.TCPSite(corredor_t, "127.0.0.1", 0)
await sitio_t.start()
BASE_T = ("http://127.0.0.1:"
f"{sitio_t._server.sockets[0].getsockname()[1]}/v1")
GET_DOC = set("""id type symbol side quantity status duration
avg_fill_price exec_quantity last_fill_price last_fill_quantity
remaining_quantity create_date transaction_date class""".split())
print("example versus the documentation:", set(EJ_T) ^ GET_DOC)
print("types:", {type(EJ_T[k]).__name__ for k in
("quantity", "avg_fill_price", "exec_quantity")})example versus the documentation: set()
types: {'float'}The Tradier client requests the session, opens the websocket, sends the form and listens. Since the stream does not
carry the price of the last execution, it deduces it: the cumulative traded value is avg_fill_price × exec_quantity,
and the difference between two events, divided by last_fill_quantity, is the price of the last thing executed.
CAB_T = {"Authorization": f"Bearer {TOKEN_TRADIER}",
"Accept": "application/json"}
async with sesion.post(BASE_T + "/accounts/events/session",
headers=CAB_T) as r:
sid = (await r.json())["stream"]["sessionid"]
ws_t = await sesion.ws_connect(BASE_T + "/accounts/events")
await ws_t.send_json({"events": ["order"], "sessionid": sid,
"excludeAccounts": []})
print("first message:", (await ws_t.receive_json())["event"])
formulario = {"class": "equity", "symbol": "ORSC", "side": "buy",
"quantity": "434", "type": "market", "duration": "day",
"tag": "R00006"}
async with sesion.post(BASE_T + "/accounts/FALSA-1/orders",
data=formulario, headers=CAB_T) as r:
alta = (await r.json())["order"]
print("POST:", alta)
previo = 0.0
while True:
e = await ws_t.receive_json()
caja = e["avg_fill_price"] * e["exec_quantity"]
ultimo = ((caja - previo) / e["last_fill_quantity"]
if e["last_fill_quantity"] else None)
previo = caja
print(f"{e['status']:<17} exec {e['exec_quantity']:>5}"
f" average {e['avg_fill_price']:<9}"
f" deduced last price {ultimo and round(ultimo, 4)}")
if e["status"] in FINALES:
break
async with sesion.get(f"{BASE_T}/accounts/FALSA-1/orders/"
f"{alta['id']}", headers=CAB_T) as r:
t_final = (await r.json())["order"]
print("same as the example:", all(t_final[k] == EJ_T[k] for k in (
"id", "quantity", "status", "avg_fill_price", "exec_quantity",
"last_fill_price", "last_fill_quantity", "remaining_quantity",
"create_date", "transaction_date")))first message: heartbeat
POST: {'id': 228005, 'status': 'ok'}
open exec 0.0 average 0.0 deduced last price None
partially_filled exec 27.0 average 234.64 deduced last price 234.64
partially_filled exec 416.0 average 234.8364 deduced last price 234.85
filled exec 434.0 average 234.8353 deduced last price 234.8099
same as the example: TrueThe deduced prices are 234.64, 234.85 and 234.8099: those of the three executions except for rounding, because the average arrives with four decimal places and its error carries over into the following executions. With a large execution after a small one that error is tiny; with many small executions it can exceed a tick. That is why, if the exact price of each execution matters (for the commission, slippage or reconciliation with the statement), you request the execution details from the broker instead of deducing them. The final query matches the example in the file on the ten fields compared.
One internal format for all
The rest of the system (positions, risk, reconciliation) should not know which broker an execution comes from. One
adapter per broker converts each message into an internal structure with fixed types: integer or Decimal quantities,
Decimal prices, times in UTC and a status from a closed list. Each broker's statuses are translated into that list;
the order's state machine is the one from lesson 4.5.2.
Mermaid source
flowchart LR A[trade_updates] --> N[Normalizer] T[Tradier events] --> N O[OANDA transactions] --> N N --> E[Internal execution] E --> P[Positions] E --> C[Reconciliation]
from decimal import Decimal
ESTADOS = {"alpaca": {"new": "aceptada", "accepted": "aceptada",
"partially_filled": "parcial",
"filled": "ejecutada", "canceled": "cancelada",
"expired": "cancelada", "rejected": "rechazada"},
"tradier": {"open": "aceptada", "pending": "aceptada",
"partially_filled": "parcial",
"filled": "ejecutada", "canceled": "cancelada",
"expired": "cancelada", "rejected": "rechazada"}}
@dataclass(frozen=True)
class Ejecucion:
broker: str
orden: str
cantidad: int
precio: Decimal
acumulada: int
estado: str
def desde_alpaca(d):
return Ejecucion("alpaca", d["order"]["client_order_id"],
int(d["qty"]), Decimal(d["price"]),
int(d["order"]["filled_qty"]),
ESTADOS["alpaca"][d["order"]["status"]])
def desde_tradier(e, precio):
return Ejecucion("tradier", str(e["id"]),
int(e["last_fill_quantity"]),
Decimal(str(precio)).quantize(Decimal("0.01")),
int(e["exec_quantity"]),
ESTADOS["tradier"][e["status"]])
print(desde_alpaca(eventos[-1]))
print(desde_tradier({"id": 228005, "status": "filled",
"last_fill_quantity": 18.0,
"exec_quantity": 434.0}, 234.8099))Ejecucion(broker='alpaca', orden='R00005', cantidad=18, precio=Decimal('234.81'), acumulada=434, estado='ejecutada')
Ejecucion(broker='tradier', orden='228005', cantidad=18, precio=Decimal('234.81'), acumulada=434, estado='ejecutada')The two messages say the same thing with different types: "18" and "234.81" as text at Alpaca; 18.0 and an
average from which 234.8099 is deduced at Tradier. The normalizer quantizes the deduced price to the 0.01 tick (which
is a decision: the deduction does not give the exact price) and delivers two executions that are identical except for
the broker and the order identifier. Decimal instead of float prevents 0.1 + 0.2 from throwing a reconciliation to
the cent out of balance.
OANDA v20: order types and the transaction stream
Lesson 3.3.3 sent market orders to OANDA with priceBound and queried by your own identifier. v20 has more order types
and a stream that reports everything that happens in the account3:
Type (type) | What it does | Accepted timeInForce |
|---|---|---|
MARKET | Executes at the market price, with optional priceBound | FOK (default) or IOC |
LIMIT | Buys at that price or better | GTC, GTD, GFD, FOK, IOC |
STOP | Triggers when the price is touched and executes as a market order | The same |
MARKET_IF_TOUCHED | Like LIMIT but triggered on touch, regardless of where the price comes from | The same |
TAKE_PROFIT, STOP_LOSS, GUARANTEED_STOP_LOSS, TRAILING_STOP_LOSS | Orders that close a trade (tradeID) | Depending on the type |
The last four can be requested together with the order that opens the position, with takeProfitOnFill,
stopLossOnFill, guaranteedStopLossOnFill and trailingStopLossOnFill: when the order executes, OANDA creates the
closing orders linked to the new trade. GTD requires gtdTime; GFD expires at 17:00 New York time. A local
validator avoids sending bodies that the server will reject:
TIF_OANDA = {"MARKET": {"FOK", "IOC"},
"LIMIT": {"GTC", "GTD", "GFD", "FOK", "IOC"},
"STOP": {"GTC", "GTD", "GFD", "FOK", "IOC"},
"MARKET_IF_TOUCHED": {"GTC", "GTD", "GFD", "FOK", "IOC"}}
def validar_oanda(o):
errores = []
if o["timeInForce"] not in TIF_OANDA[o["type"]]:
errores.append(f"{o['type']} does not accept {o['timeInForce']}")
if o["timeInForce"] == "GTD" and "gtdTime" not in o:
errores.append("GTD without gtdTime")
if o["type"] != "MARKET" and "price" not in o:
errores.append("price missing")
u, p = float(o["units"]), float(o.get("price", "nan"))
sl = float(o.get("stopLossOnFill", {}).get("price", "nan"))
if u > 0 and sl >= p or u < 0 and sl <= p:
errores.append("stop on the wrong side")
return errores
limite = {"type": "LIMIT", "instrument": "EUR_USD", "units": "10000",
"price": "1.16300", "timeInForce": "GTD",
"gtdTime": "2025-10-15T20:00:00.000000000Z",
"positionFill": "DEFAULT",
"stopLossOnFill": {"price": "1.15900"},
"takeProfitOnFill": {"price": "1.17100"},
"clientExtensions": {"id": "R4-fx-0001", "tag": "rebalanceo"}}
print(validar_oanda(limite))
print(validar_oanda(limite | {"type": "MARKET"}))
print(validar_oanda(limite | {"stopLossOnFill": {"price": "1.16500"}}))
print(json.dumps({"order": limite})[:72] + "…")[]
['MARKET does not accept GTD']
['stop on the wrong side']
{"order": {"type": "LIMIT", "instrument": "EUR_USD", "units": "10000", "…The first order passes. Turned into a market order with GTD, the validator stops it (a market order only accepts
FOK or IOC); with the stop above the purchase price, too. The final body goes inside an order field, as in
lesson 3.3.3.
The fill transaction, against its definition
When the order executes, OANDA creates an ORDER_FILL transaction. The example file includes one; the cell compares it
with the list of OrderFillTransaction fields in the transaction definitions documentation:
FILL_DOC = set("""id time userID accountID batchID requestID type
orderID clientOrderID instrument units gainQuoteHomeConversionFactor
lossQuoteHomeConversionFactor homeConversionFactors price fullVWAP
fullPrice reason pl quotePL financing baseFinancing quoteFinancing
commission guaranteedExecutionFee quoteGuaranteedExecutionFee
accountBalance tradeOpened tradesClosed tradeReduced
halfSpreadCost""".split())
OBSOLETOS = {"price", "gainQuoteHomeConversionFactor",
"lossQuoteHomeConversionFactor"}
tx = EJ["oanda_v20"]["transaction"]
print("undocumented:", set(tx) - FILL_DOC)
print("deprecated in use:", sorted(OBSOLETOS & set(tx)))
print("official price (tradeOpened):", tx["tradeOpened"]["price"],
"| fullVWAP:", tx["fullVWAP"], "| type:",
type(tx["units"]).__name__)undocumented: {'requestedUnits'}
deprecated in use: ['gainQuoteHomeConversionFactor', 'lossQuoteHomeConversionFactor', 'price']
official price (tradeOpened): 1.16451 | fullVWAP: 1.16451 | type: strThe example has one field the definition does not include, requestedUnits, and three that the documentation marks as
deprecated: price (“the tradesClosed, tradeReduced and tradeOpened fields contain the exact and official
price”) and the two conversion factors, which “will be removed”. A parser that depends on them will break some day; the
code in this lesson reads the price from tradeOpened and does not use requestedUnits.
A transaction stream with a gap
The transaction stream (GET /v3/accounts/{accountID}/transactions/stream, on the stream-… servers) is an HTTP
response that never ends: chunked encoding, one JSON object per line and a HEARTBEAT every 5 seconds with
lastTransactionID, the identifier of the account's last transaction3. That field turns the heartbeat into a check:
if it is greater than the last transaction seen, something has been lost, and
GET /v3/accounts/{accountID}/transactions/sinceid?id=… returns the transactions after an identifier. Lesson 1.4.1
detected gaps by sequence number; here the heartbeat gives the gap away, even if no message arrives after the lost one.
The local server emits, with times from a logical clock, the example's order and fill, heartbeats every 5 seconds and two more transactions, of which it “loses” the first:
fill = {k: v for k, v in tx.items() if k != "requestedUnits"}
T0 = 1_760_448_900 # 2025-10-14 13:35:00 UTC
def hora(s):
import datetime as dt
t = dt.datetime.fromtimestamp(T0 + s, dt.UTC)
return t.strftime("%Y-%m-%dT%H:%M:%S.000000000Z")
TRANS = [
(0.9, {"id": "6409", "type": "MARKET_ORDER",
"instrument": "EUR_USD", "units": "10000"}),
(1.0, fill),
(7.2, {"id": "6411", "type": "STOP_LOSS_ORDER", "tradeID": "6410",
"price": "1.15900"}),
(11.6, {"id": "6412", "type": "ORDER_CANCEL", "orderID": "6411",
"reason": "CLIENT_REQUEST"}),
]
PERDIDA = "6411"
def linea_tiempo(fin=20):
sucesos = [(s, t) for s, t in TRANS]
sucesos += [(s, None) for s in range(5, fin + 1, 5)]
ultima = "6408"
for s, t in sorted(sucesos, key=lambda x: x[0]):
if t is None:
yield s, {"type": "HEARTBEAT", "lastTransactionID": ultima,
"time": hora(s)}
else:
ultima = t["id"]
yield s, t | {"time": hora(s)}
async def oanda_stream(req):
r = web.StreamResponse()
await r.prepare(req)
for s, msg in linea_tiempo():
if msg.get("id") != PERDIDA:
await r.write((json.dumps(msg) + "\n").encode())
return r
async def oanda_sinceid(req):
desde = int(req.query["id"])
return web.json_response({"transactions": [
t | {"time": hora(s)} for s, t in TRANS
if int(t["id"]) > desde],
"lastTransactionID": TRANS[-1][1]["id"]})
app_o = web.Application()
app_o.router.add_get("/v3/accounts/{a}/transactions/stream",
oanda_stream)
app_o.router.add_get("/v3/accounts/{a}/transactions/sinceid",
oanda_sinceid)
corredor_o = web.AppRunner(app_o)
await corredor_o.setup()
sitio_o = web.TCPSite(corredor_o, "127.0.0.1", 0)
await sitio_o.start()
BASE_O = ("http://127.0.0.1:"
f"{sitio_o._server.sockets[0].getsockname()[1]}"
"/v3/accounts/101-004-0418272-001")
print([(s, m.get("id", m["type"])) for s, m in linea_tiempo()])[(0.9, '6409'), (1.0, '6410'), (5, 'HEARTBEAT'), (7.2, '6411'), (10, 'HEARTBEAT'), (11.6, '6412'), (15, 'HEARTBEAT'), (20, 'HEARTBEAT')]The client reads line by line. Each transaction updates the last one seen; each heartbeat is compared with it, and if the heartbeat is ahead, the client requests what is missing before continuing:
vistas, ultima = [], 6408
async with sesion.get(BASE_O + "/transactions/stream") as r:
async for crudo in r.content:
m = json.loads(crudo)
if m["type"] != "HEARTBEAT":
if m["id"] in vistas:
print(" repeated", m["id"],
"from the stream: discarded")
continue
ultima = int(m["id"])
vistas.append(m["id"])
continue
if int(m["lastTransactionID"]) > ultima:
print(f"{m['time'][11:19]} heartbeat {m['lastTransactionID']}"
f" > last seen {ultima}: requesting sinceid")
async with sesion.get(BASE_O + "/transactions/sinceid",
params={"id": str(ultima)}) as s:
for t in (await s.json())["transactions"]:
if t["id"] not in vistas:
vistas.append(t["id"])
print(" recovered", t["id"], t["type"])
ultima = max(ultima, int(t["id"]))
print("transactions processed:", vistas)13:35:10 heartbeat 6411 > last seen 6410: requesting sinceid
recovered 6411 STOP_LOSS_ORDER
recovered 6412 ORDER_CANCEL
repeated 6412 from the stream: discarded
transactions processed: ['6409', '6410', '6411', '6412']Transaction 6411 (the creation of the stop) is lost at 7.2 seconds. The heartbeat at 10 seconds says that the
account's last transaction is 6411 and the client has only seen 6410: it requests sinceid with 6410 and recovers
6411. The simulated server replies with the full state of the account, so it also returns 6412, which the stream had
not delivered yet; when it arrives through the stream, the client discards it as a repeat. That is what happens with a
real broker: the REST query and the stream are not in sync, and processing has to be idempotent by transaction
identifier. Without the heartbeat check, the system would not know there is a stop in the account. The figure shows the
timeline: transactions on top, heartbeats below and, in amber, the heartbeat that detects the gap.
MetaTrader 5: Windows only and no stream
The MetaTrader 5 Python package (MetaTrader5, version 5.0.6231 on PyPI, from September 2026) only publishes
win_amd64 wheels: there is no version for Linux or macOS, and no source code to compile. Its description on PyPI is
“API Connector to MetaTrader 5 Terminal”, and the MQL5 documentation explains that it gets the data “via interprocess
communication directly from the MetaTrader 5 terminal”; initialize() starts the terminal if needed, and its path
parameter is the path to the terminal executable4. In practice, a system with MetaTrader 5 runs on a Windows machine
with the terminal open and the broker session logged in; the rest of the system can live on Linux and talk to that
process over the network, with the isolation covered in lesson 4.5.1.
Lesson 3.3.3 sent market orders (TRADE_ACTION_DEAL) and classified the return codes. A system also needs pending
orders, the pre-check and knowing which orders are its own:
import MetaTrader5 as mt5 # Windows only
if not mt5.initialize(login=int(os.environ["MT5_LOGIN"]),
password=os.environ["MT5_PASSWORD"],
server=os.environ["MT5_SERVIDOR"]):
raise RuntimeError(mt5.last_error())
cuenta = mt5.account_info()
assert cuenta.trade_mode == mt5.ACCOUNT_TRADE_MODE_DEMO # paper
peticion = {
"action": mt5.TRADE_ACTION_PENDING, "symbol": "EURUSD",
"volume": 0.10, "type": mt5.ORDER_TYPE_BUY_LIMIT,
"price": 1.16300, "sl": 1.15900, "tp": 1.17100,
"type_time": mt5.ORDER_TIME_SPECIFIED,
"expiration": 1760558400, # Unix seconds
"type_filling": mt5.ORDER_FILLING_RETURN,
"magic": 418272, "comment": "R4-fx-0001",
}
chequeo = mt5.order_check(peticion) # margin, without sending
print(chequeo.retcode, chequeo.margin, chequeo.margin_free)
resultado = mt5.order_send(peticion)
propias = [o for o in mt5.orders_get(symbol="EURUSD")
if o.magic == 418272] # only the system's own
posiciones = mt5.positions_get(symbol="EURUSD")
mt5.shutdown()TRADE_ACTION_PENDINGplaces a pending order (ORDER_TYPE_BUY_LIMIT,SELL_LIMIT,BUY_STOP,SELL_STOP,BUY_STOP_LIMIT,SELL_STOP_LIMIT);TRADE_ACTION_SLTPchanges the stop and target of an open position;TRADE_ACTION_MODIFYandTRADE_ACTION_REMOVEmodify or remove a pending order.order_checkchecks funds and margin and returns anMqlTradeCheckResult; the documentation warns that a successful check does not guarantee that the trade will execute.magicis the number of the sending system: with it you separate your own orders and positions from those the user opens by hand in the same terminal.account_info().trade_modesays whether the account is demo (ACCOUNT_TRADE_MODE_DEMO), contest or real: it is MetaTrader 5's paper-or-live check, because the URL does not change.
The order_send result in the example file (metatrader5.resultado) has the fields from the order_send and
MqlTradeResult documentation, and its request, those of the request structure. The numeric values of the constants
are not on the MQL5 pages (only their names); they come from the package itself, which defines them in
MetaTrader5/__init__.py:
RESULT_DOC = {"retcode", "deal", "order", "volume", "price", "bid",
"ask", "comment", "request_id", "retcode_external",
"request"}
REQUEST_DOC = {"action", "magic", "order", "symbol", "volume", "price",
"stoplimit", "sl", "tp", "deviation", "type",
"type_filling", "type_time", "expiration", "comment",
"position", "position_by"}
CONST = {"TRADE_ACTION_DEAL": 1, "TRADE_ACTION_PENDING": 5,
"TRADE_ACTION_SLTP": 6, "ORDER_TYPE_BUY": 0,
"ORDER_TYPE_BUY_LIMIT": 2, "ORDER_FILLING_IOC": 1,
"ORDER_FILLING_RETURN": 2, "TRADE_RETCODE_DONE": 10009}
res = EJ["metatrader5"]["resultado"]
print(set(res) == RESULT_DOC, set(res["request"]) == REQUEST_DOC)
nombre = {v: k for k, v in CONST.items() if k.startswith("TRADE_A")}
print("action:", nombre[res["request"]["action"]],
"| buy type:",
res["request"]["type"] == CONST["ORDER_TYPE_BUY"],
"| IOC filling:",
res["request"]["type_filling"] == CONST["ORDER_FILLING_IOC"],
"| done:", res["retcode"] == CONST["TRADE_RETCODE_DONE"])
print(f"price {res['price']} = ask {res['ask']}; spread "
f"{(res['ask'] - res['bid']) * 1e5:.1f} points")True True
action: TRADE_ACTION_DEAL | buy type: True | IOC filling: True | done: True
price 1.16452 = ask 1.16452; spread 8.0 pointsThe fields match the documentation and the codes match the package constants: a market buy (TRADE_ACTION_DEAL,
ORDER_TYPE_BUY) with IOC filling, executed (10009) at the ask.
No stream: polling without losing ticks
The package's list of functions has no subscriptions or callbacks: data is requested (symbol_info_tick,
copy_ticks_from, market_book_get), it does not arrive on its own. This is a deduction from the documentation, which
lists functions and does not say “there is no stream” anywhere, but it has a practical consequence: a system that
queries symbol_info_tick every half second only sees the last tick of each interval. copy_ticks_from(symbol, from, count, flags) returns the ticks from a given time, with time_msc in milliseconds; requesting from the last
time_msc seen loses none.
The cell emulates both approaches with one minute of synthetic ticks (Poisson arrivals, about four per second, with a fixed seed):
import numpy as np
rng = np.random.default_rng(4403)
llegadas = np.cumsum(rng.exponential(250, 400)) # ms
ticks = [(int(t), round(1.16450 + 1e-5 * k, 5))
for k, t in enumerate(llegadas[llegadas < 60_000])]
def symbol_info_tick(ahora):
previos = [t for t in ticks if t[0] <= ahora]
return previos[-1] if previos else None
def copy_ticks_from(desde, cuantos=1000, ahora=None):
return [t for t in ticks if desde <= t[0] <= ahora][:cuantos]
vistos_sondeo = {symbol_info_tick(ms) for ms in range(500, 60_001, 500)}
vistos_sondeo.discard(None)
vistos_copia, ultimo = [], -1
for ms in range(500, 60_001, 500):
nuevos = [t for t in copy_ticks_from(ultimo, ahora=ms)
if t[0] > ultimo]
vistos_copia += nuevos
if nuevos:
ultimo = nuevos[-1][0]
print(f"ticks in the minute: {len(ticks)}")
print(f"polling symbol_info_tick every 500 ms: {len(vistos_sondeo)}")
print(f"copy_ticks_from from the last time_msc: {len(vistos_copia)}")ticks in the minute: 238
polling symbol_info_tick every 500 ms: 103
copy_ticks_from from the last time_msc: 238Polling sees 103 of 238 ticks: less than half. Requesting from the last time_msc sees them all. There is a detail in
the code: copy_ticks_from includes the ticks with the same time as desde, so you have to filter out those already
seen (> ultimo); if two ticks share a millisecond, filtering by time is not enough and you also have to compare the
content.
What goes wrong with these APIs
- Paper and live mixed up. The live URL with a paper key gives a 401; the live URL with the live key, when you
believed you were on paper, gives real orders. The mode is decided in the execution environment and checked (URL at
Alpaca, Tradier and OANDA;
trade_modein MetaTrader 5). - Number types. Text at Alpaca and OANDA, numbers at Tradier,
floatin MetaTrader 5. Without normalizing,"27" + "389"is"27389". - Fields that change.
order_typedeprecated at Alpaca;priceand the conversion factors deprecated at OANDA;exec_quantityandexecuted_quantityon two Tradier pages. The adapter should fail loudly if a field it needs is missing, not fill it with zero. - Lost messages. A websocket that reconnects does not resend what happened while it was down: on reconnecting, you
query the state of open orders over REST; at OANDA,
sinceidfrom the last transaction seen. - Errors with a 200. Tradier can return 200 to the
POSTand reject the order afterwards: thePOSTresponse is not the confirmation; the order's status is. - Rate limits. 60 orders per minute at Tradier, 120 requests per second per IP at OANDA, 429 at Alpaca with the
X-RateLimit-*headers. A retry loop without a wait exhausts them (lesson 1.4.1). - Windows required for MetaTrader 5, and a terminal that has to be monitored like any other process: if it closes
or loses its session,
initializefails and the system has to know.
await nucleo.cerrar() # the server releases the websockets
await ws.close()
await ws_t.close()
await sesion.close()
for c in (corredor, corredor_t, corredor_o):
await c.cleanup()
print("simulators shut down")simulators shut downExercises
- Add to the core and to the Alpaca facade a limit order that does not execute and is canceled with
DELETE /v2/orders/{order_id}: the stream must emitnewand thencanceled, and the final query must givestatuscanceledwithfilled_qty"0". Use a script with no executions.
Solution
async def alpaca_cancelar(req):
o = nucleo.ordenes.get(req.match_info["oid"])
if o is None or o.estado in FINALES:
return error(422, 42210000, "order is not cancelable")
o.estado, o.ultima = "canceled", "2025-10-14T13:40:00.000000Z"
await nucleo.publicar("canceled", o, ts=o.ultima)
return web.Response(status=204)
nucleo.guiones["R9100"] = ("2025-10-14T13:36:00.000000Z",
"2025-10-14T13:36:00.030000Z", [])
app_c = web.Application()
app_c.router.add_post("/v2/orders", alpaca_post)
app_c.router.add_delete("/v2/orders/{oid}", alpaca_cancelar)
app_c.router.add_get("/stream", alpaca_stream)
corredor_c = web.AppRunner(app_c)
await corredor_c.setup()
sitio_c = web.TCPSite(corredor_c, "127.0.0.1", 0)
await sitio_c.start()
BASE_C = ("http://127.0.0.1:"
f"{sitio_c._server.sockets[0].getsockname()[1]}")
async with ClientSession() as s:
w = await s.ws_connect(BASE_C + "/stream")
await w.send_json({"action": "auth", "key": "PK-FALSA-PAPEL",
"secret": "FALSA-SECRETO-PAPEL"})
await w.receive_json()
await w.send_json({"action": "listen",
"data": {"streams": ["trade_updates"]}})
await w.receive_json()
c = cuerpo | {"client_order_id": "R9100", "qty": "100",
"type": "limit", "limit_price": "230.00"}
async with s.post(BASE_C + "/v2/orders", json=c, headers=CAB) as r:
oid = (await r.json())["id"]
print("new:", (await w.receive_json())["data"]["event"])
async with s.delete(f"{BASE_C}/v2/orders/{oid}",
headers=CAB) as r:
print("DELETE:", r.status)
d = (await w.receive_json())["data"]
print(d["event"], d["order"]["status"], d["order"]["filled_qty"])
await nucleo.cerrar()
await w.close()
await corredor_c.cleanup()new: new
DELETE: 204
canceled canceled 0The simulator reuses the example's order identifier for any Alpaca order (a simplification: at Alpaca each order has
its own). The cancellation emits canceled with filled_qty "0". With a real order there are two more things: an
execution can arrive between the request and the confirmation (the pending_cancel event exists for that reason), and
the DELETE can fail if the order is already in a final state. The status is always given by the stream or the query,
not by the DELETE response.
- With the Tradier events, deducing the price of each execution accumulates the rounding error of the average. Compute that error for R00006 by comparing each deduced price with the true execution price in the script.
Solution
previo, errores = 0.0, []
acum_q, acum_c = 0, 0.0
for ts, q, px, eid in guion_r5[2]:
acum_q += q
acum_c += q * px
media = round(acum_c / acum_q, 4) # what Tradier publishes
caja = media * acum_q
deducido = (caja - previo) / q
previo = caja
errores.append(round(deducido - px, 6))
print("error of the deduced price per execution:", errores)error of the deduced price per execution: [0.0, 3.2e-05, -0.000122]The first execution comes out exact and the other two deviate by 0.000032 and −0.000122 USD, about a hundredth of a cent. The rounding error of the average (up to 0.00005) is multiplied by the cumulative quantity and divided by that of the last execution: with 18 shares after 416, the factor is 434/18 ≈ 24. It is small compared with the 0.01 tick, but it is not zero and it grows with many small executions: to reconcile to the cent with the statement you have to request the executions with their price, not deduce it.
- The level project's system has to work with Alpaca on paper and, if MetaTrader 5 is ever used, with a currency broker. Describe how you would organize the code so that the rest of the system depends on neither of them, nor on MetaTrader 5 only working on Windows.
Solution
One adapter per broker with the same interface (send, cancel, query by your own identifier, execution events in the
internal format of the normalization section) and the rest of the system talking only to that interface. The Alpaca
adapter is a REST and websocket client that can run anywhere. The MetaTrader 5 one is split in two: a small process on
Windows, next to the terminal, that calls the MetaTrader5 package, polls ticks and orders from the last one seen and
publishes events; and a client in the main system that talks to that process over the network. The Windows process
makes no decisions: it translates. That way the system does not import MetaTrader5, the tests use a simulator like
the one in this lesson and changing brokers means changing adapters. The choice of mode (paper or live) still belongs
to the environment, and each adapter checks at startup that the requested mode is the account's (URL at Alpaca,
trade_mode in MetaTrader 5).
Footnotes
-
Alpaca, Create an Order (
CreateOrderRequestandOrderschemas), Get Order by Client Order ID, Authentication, Paper Trading and WebSocket Streaming;alpaca-py0.44.0 on PyPI. Accessed on October 22, 2026. ↩ ↩2 ↩3 -
Tradier, Endpoints, Place Order, Get an order, Error responses, Response format, Account streaming responses, Account WebSocket streaming, FAQ and Rate limiting. Accessed on October 22, 2026. ↩ ↩2
-
OANDA, Order Definitions, Transaction Definitions, Transaction Endpoints (stream and
sinceid) and Development Guide (URLs and limits). Accessed on October 22, 2026. ↩ ↩2 ↩3 -
MetaTrader5 on PyPI (version 5.0.6231,
win_amd64wheels only); MQL5, MetaTrader for Python, initialize, order_send, order_check, copy_ticks_from, trade operation types and return codes. Accessed on October 22, 2026. ↩ ↩2
Each answer is folded under its question
Self-assessment
An Alpaca order sent with
qty434 arrives on the stream with"filled_qty": "416". What type does that value have in the JSON and what do you have to do with it?- AIt is text: you have to convert it (to
Decimalorfloat) before operating on it, just likeprice,qtyandfilled_avg_price - BIt is an integer: it is used as is
- CIt is a simulator bug
- DIt depends on the day
Show answer
Answer A. It is text: you have to convert it (to
Decimalorfloat) before operating on it, just likeprice,qtyandfilled_avg_priceAlpaca's
Orderschema declares quantities and prices asstring. Tradier, on the other hand, gives them as JSON numbers. A normalizer converts both to the same type before they reach the rest of the system.- AIt is text: you have to convert it (to
The Tradier account stream does not publish
last_fill_price. How do you get the price of the last execution?- AYou can't
- BIt is always equal to
avg_fill_price - CWith
avg_fill_priceandexec_quantitybefore and after: (new average × new quantity − previous average × previous quantity) /last_fill_quantity - DBy requesting the order book
Show answer
Answer C. With
avg_fill_priceandexec_quantitybefore and after: (new average × new quantity − previous average × previous quantity) /last_fill_quantityThe cumulative traded value is average × quantity; the difference between two events is the value of the last execution. In the lesson this gives 234.85 and 234.8099 for the last two executions of R00005 (234.85 and 234.81): the difference is the rounding of the average to four decimal places.
On the OANDA transaction stream a
HEARTBEATarrives withlastTransactionID6413 and the client has only seen up to 6411. What does it do?- ANothing: heartbeats carry no information
- BCloses the account
- CWaits for 6412 to arrive on its own
- DRequests
GET /v3/accounts/{accountID}/transactions/sinceid?id=6411to recover the missing ones before continuing
Show answer
Answer D. Requests
GET /v3/accounts/{accountID}/transactions/sinceid?id=6411to recover the missing ones before continuingThe heartbeat says which is the account's last transaction. If it is greater than the last one seen, something has been lost; the
sinceidendpoint returns the transactions after a given identifier.Why can a system on Linux not do
import MetaTrader5?- ABecause the package is paid
- BBecause on PyPI there are only
win_amd64wheels: the package is a connector to the MetaTrader 5 terminal, which is a Windows program - CBecause it needs Python 2
- DBecause the broker forbids it
Show answer
Answer B. Because on PyPI there are only
win_amd64wheels: the package is a connector to the MetaTrader 5 terminal, which is a Windows programThe package's PyPI page only publishes 64-bit Windows wheels and its summary defines it as “API Connector to MetaTrader 5 Terminal”. The Python code talks to the terminal through interprocess communication.
With MetaTrader 5, a system queries
symbol_info_tickevery half second. What does it lose?- AAll the ticks that arrive between two queries, except the last;
copy_ticks_fromfrom the lasttime_mscseen recovers them - BNothing
- COnly the weekend ones
- DPending orders
Show answer
Answer A. All the ticks that arrive between two queries, except the last;
copy_ticks_fromfrom the lasttime_mscseen recovers themsymbol_info_tickreturns the last tick. In the lesson's simulation, polling sees 103 of the 238 ticks in one minute; requesting the ticks from the last timestamp seen collects them all.- AAll the ticks that arrive between two queries, except the last;
Key points
- Alpaca and OANDA separate paper and live by URL and by credentials; Tradier by URL and token, with data delayed 15 minutes in the sandbox. A paper key does not work in live, and the system should check that before sending.
- Each API reports an execution in its own way: Alpaca gives numbers as text and an
execution_id; Tradier, as numbers and withoutlast_fill_priceon the stream. A normalizer converts them to an internal format before touching positions. - The order's identity is your own identifier (
client_order_idat Alpaca,tagat Tradier,clientExtensionsat OANDA,magicandcommentin MT5): you query by it before resending. - The OANDA transaction stream is chunked HTTP with heartbeats every 5 seconds that carry
lastTransactionID; a gap is filled withtransactions/sinceid. - The
MetaTrader5package only exists for Windows and has no stream: you poll, and so as not to lose ticks you request them from the lasttime_mscseen.