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.

Fig. — Order R00005 on Alpaca's trade_updates stream: shares filled since submission
Level 4 lessons
  1. 4.1 · Portfolio theory and optimisation

    1. 4.1.1 Markowitz: efficient frontier and real constraints
    2. 4.1.2 Covariance estimation: shrinkage, factor models and denoising
    3. 4.1.3 Black-Litterman: from market equilibrium to views
    4. 4.1.4 Risk parity, minimum variance, HRP and HERC
    5. 4.1.5 Optimization with CVaR, drawdown, turnover and costs
    6. 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
    7. 4.1.7 Rebalancing and strategy portfolios
    8. 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
  2. 4.2 · Risk management and position sizing

    1. 4.2.1 Risk per trade, stops and risk of ruin
    2. 4.2.2 Kelly criterion and volatility targeting: how much to risk
    3. 4.2.3 VaR, Expected Shortfall and VaR backtesting
    4. 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
    5. 4.2.5 Drawdown control and kill switch
    6. 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
  3. 4.3 · Microstructure and execution

    1. 4.3.1 Implementation shortfall, VWAP, TWAP and Almgren-Chriss
    2. 4.3.2 Order flow imbalance, microstructure signals and latency
  4. 4.4 · Brokers and APIs

    1. 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
    2. 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
    4. 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
    5. 4.4.5 Secure credentials: environment variables, secret managers and least privilege
  5. 4.5 · Live trading system

    1. 4.5.1 Live system architecture: from data to reconciliation
    2. 4.5.2 Order management: states, partial fills, cancellations and reconnections
    3. 4.5.3 Paper trading, shadow trading and slippage monitoring
    4. 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
    5. 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
    6. 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.

SystemAlpaca REST and WSTradier REST and WSOANDA REST and HTTPMT5 packageWindows terminal
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]
AlpacaTradierOANDA v20MetaTrader 5
TransportREST JSON; websocket for trade_updatesREST with forms; JSON response if requested (XML by default); account websocket (beta)REST JSON; streams over chunked HTTPCalls to a local terminal through interprocess communication
AuthenticationAPCA-API-KEY-ID and APCA-API-SECRET-KEY headersAuthorization: BearerAuthorization: BearerLogin, password and server in initialize
Paper and livepaper-api.alpaca.markets and api.alpaca.markets, with different keyssandbox.tradier.com and api.tradier.com; the sandbox has data delayed 15 minutesapi-fxpractice.oanda.com and api-fxtrade.oanda.com; streams on stream-…The broker's demo or live account, in the same terminal
NumbersText ("434", "234.8353")JSON numbersTextPython float
Own identifierclient_order_id (up to 128 characters)tagclientExtensions.idmagic 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:

IN [01]python
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)
OUT [01]Each broker's URL by mode, with live mode locked
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 environment

By 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:

  • type can be market, limit, stop, stop_limit or trailing_stop (for stocks; for crypto, only market, limit and stop_limit), and time_in_force, day, gtc, opg (open), cls (close), ioc or fok.
  • qty and notional (amount in dollars, only with market orders and day) are mutually exclusive. Quantities and prices are sent and received as text.
  • client_order_id is your own identifier, up to 128 characters; if you do not send one, Alpaca generates it. The order is then queried by it at GET /v2/orders:by_client_order_id?client_order_id=….
  • order_class allows bracket, oco and oto orders with take_profit and stop_loss.
  • The error responses documented for the POST are 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:

IN [02]alpaca_orden.pypython
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 loop

The 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:

IN [03]python
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})
OUT [03]The example's fields versus Alpaca's Order schema
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.

IN [04]python
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]])
OUT [04]The core of the simulated broker
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.

IN [05]python
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.

IN [06]python
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:
        break
OUT [06]Sending R00005 and rebuilding it from trade_updates
authorized
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:

IN [07]python
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"])
OUT [07]Query by client_order_id and comparison with the example
14 common fields; different: []
rebuilt: 434 234.8353
queried: 434 234.8353 filled

The 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.

IN [08]python
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']}")
OUT [08]422, 403 and a repeated identifier
tif gtd          422 invalid type or time_in_force
10,000 shares    403 insufficient buying power
R00005 again     422 client_order_id must be unique

The 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) to POST /v1/accounts/{account_id}/orders, with class (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, stop and tag.
  • 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 POST returns {"order": {"id": …, "status": "ok", …}}. The errors page warns that, when there is an error in the systems behind it, the POST may return a 200: you have to query the order and look at its status and its errors property.
  • 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/session returns a sessionid that has to be used within 5 minutes, and on wss://ws.tradier.com/v1/accounts/events you 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 a sandbox-ws.tradier.com server. 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.
IN [09]tradier_orden.pypython
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).

IN [10]python
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")})
OUT [10]The Tradier facade: another server on the same core
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.

IN [11]python
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")))
OUT [11]R00006 via Tradier: the price of each execution, deduced
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: True

The 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.

trade_updatesNormalizerTradier eventsOANDA transactionsInternal executionPositionsReconciliation
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]
IN [12]python
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))
OUT [12]Alpaca and Tradier executions in an internal format
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 doesAccepted timeInForce
MARKETExecutes at the market price, with optional priceBoundFOK (default) or IOC
LIMITBuys at that price or betterGTC, GTD, GFD, FOK, IOC
STOPTriggers when the price is touched and executes as a market orderThe same
MARKET_IF_TOUCHEDLike LIMIT but triggered on touch, regardless of where the price comes fromThe same
TAKE_PROFIT, STOP_LOSS, GUARANTEED_STOP_LOSS, TRAILING_STOP_LOSSOrders 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:

IN [13]python
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] + "…")
OUT [13]A GTD limit order with stop and take profit on fill
[]
['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:

IN [14]python
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__)
OUT [14]The example's ORDER_FILL versus OrderFillTransaction
undocumented: {'requestedUnits'}
deprecated in use: ['gainQuoteHomeConversionFactor', 'lossQuoteHomeConversionFactor', 'price']
official price (tradeOpened): 1.16451 | fullVWAP: 1.16451 | type: str

The 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:

IN [15]python
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()])
OUT [15]OANDA transaction server with a lost line
[(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:

IN [16]python
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)
OUT [16]Stream client: the heartbeat gives the gap away
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.

Fig. — Simulated OANDA transaction stream: the heartbeat at 10 seconds reveals the lost transaction

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:

IN [17]mt5_pendientes.pypython
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_PENDING places a pending order (ORDER_TYPE_BUY_LIMIT, SELL_LIMIT, BUY_STOP, SELL_STOP, BUY_STOP_LIMIT, SELL_STOP_LIMIT); TRADE_ACTION_SLTP changes the stop and target of an open position; TRADE_ACTION_MODIFY and TRADE_ACTION_REMOVE modify or remove a pending order.
  • order_check checks funds and margin and returns an MqlTradeCheckResult; the documentation warns that a successful check does not guarantee that the trade will execute.
  • magic is 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_mode says 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:

IN [18]python
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")
OUT [18]The example's OrderSendResult and the package constants
True True
action: TRADE_ACTION_DEAL | buy type: True | IOC filling: True | done: True
price 1.16452 = ask 1.16452; spread 8.0 points

The 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):

IN [19]python
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)}")
OUT [19]Polling the last tick versus requesting ticks from the last one seen
ticks in the minute: 238
polling symbol_info_tick every 500 ms: 103
copy_ticks_from from the last time_msc: 238

Polling 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.

Fig. — Ten seconds of synthetic ticks: all of them, as copy_ticks_from returns them, and those seen by polling symbol_info_tick every 500 ms

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_mode in MetaTrader 5).
  • Number types. Text at Alpaca and OANDA, numbers at Tradier, float in MetaTrader 5. Without normalizing, "27" + "389" is "27389".
  • Fields that change. order_type deprecated at Alpaca; price and the conversion factors deprecated at OANDA; exec_quantity and executed_quantity on 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, sinceid from the last transaction seen.
  • Errors with a 200. Tradier can return 200 to the POST and reject the order afterwards: the POST response 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, initialize fails and the system has to know.
IN [20]python
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")
OUT [20]Shutting down the simulator servers
simulators shut down

Exercises

  1. 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 emit new and then canceled, and the final query must give status canceled with filled_qty "0". Use a script with no executions.
Solution
IN [21]python
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()
OUT [21]A canceled limit order
new: new
DELETE: 204
canceled canceled 0

The 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.

  1. 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
IN [22]python
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)
OUT [22]Error of the price deduction from rounding the average
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.

  1. 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

  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. ↩ ↩2 ↩3

  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. ↩ ↩2

  3. OANDA, Order Definitions, Transaction Definitions, Transaction Endpoints (stream and sinceid) and Development Guide (URLs and limits). Accessed on October 22, 2026. ↩ ↩2 ↩3

  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. ↩ ↩2

Each answer is folded under its question

Self-assessment

  1. An Alpaca order sent with qty 434 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 Decimal or float) before operating on it, just like price, qty and filled_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 Decimal or float) before operating on it, just like price, qty and filled_avg_price

    Alpaca's Order schema declares quantities and prices as string. 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.

  2. 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_price and exec_quantity before 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_price and exec_quantity before and after: (new average × new quantity − previous average × previous quantity) / last_fill_quantity

    The 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.

  3. On the OANDA transaction stream a HEARTBEAT arrives with lastTransactionID 6413 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=6411 to recover the missing ones before continuing
    Show answer

    Answer D. Requests GET /v3/accounts/{accountID}/transactions/sinceid?id=6411 to recover the missing ones before continuing

    The heartbeat says which is the account's last transaction. If it is greater than the last one seen, something has been lost; the sinceid endpoint returns the transactions after a given identifier.

  4. Why can a system on Linux not do import MetaTrader5?

    • ABecause the package is paid
    • BBecause on PyPI there are only win_amd64 wheels: 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_amd64 wheels: the package is a connector to the MetaTrader 5 terminal, which is a Windows program

    The 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.

  5. With MetaTrader 5, a system queries symbol_info_tick every half second. What does it lose?

    • AAll the ticks that arrive between two queries, except the last; copy_ticks_from from the last time_msc seen 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_from from the last time_msc seen recovers them

    symbol_info_tick returns 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.

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 without last_fill_price on the stream. A normalizer converts them to an internal format before touching positions.
  • The order's identity is your own identifier (client_order_id at Alpaca, tag at Tradier, clientExtensions at OANDA, magic and comment in 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 with transactions/sinceid.
  • The MetaTrader5 package only exists for Windows and has no stream: you poll, and so as not to lose ticks you request them from the last time_msc seen.

Resources

Data