---
title: "4.4.3 Alpaca, Tradier, MetaTrader 5 and OANDA v20"
description: "Four broker APIs: paper and live environments, orders, execution streams and their differences, run against a local simulator."
url: "https://pythonparatrading.com/en/python-trading-course/level-4/alpaca-tradier-metatrader-5-and-oanda/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-4/alpaca-tradier-metatrader-5-and-oanda.png"
---

# 4.4.3 · 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.

- Course: [Level 4: Portfolios, risk, execution and live trading](https://pythonparatrading.com/en/python-trading-course/level-4/)
- Module: 4.4 · Brokers and APIs
- Duration: 75 min
- Prerequisites: [1.3.6 · Brokers as market data sources](https://pythonparatrading.com/en/python-trading-course/level-1/brokers-as-market-data-sources/), [1.4.1 · Downloading over REST and websockets: pagination, rate limits and retries](https://pythonparatrading.com/en/python-trading-course/level-1/rest-websockets-pagination-and-retries/), [3.3.3 · Currency risk in multi-currency portfolios; execution via OANDA, IBKR or MT5](https://pythonparatrading.com/en/python-trading-course/level-3/currency-risk-and-fx-execution/), [3.7.4 · ccxt for trading on multiple exchanges](https://pythonparatrading.com/en/python-trading-course/level-3/ccxt-multi-exchange-trading/)

## By the end of this lesson

- Distinguish the architecture of each API (REST and websocket, form and JSON, chunked HTTP, Windows terminal) and its paper and live environments.
- Send orders to Alpaca and Tradier with your own identifier and rebuild their state from each one's execution stream.
- Normalize executions from brokers that give numbers as text or as numbers and do not publish the same fields.
- Use the OANDA v20 order types with dependent orders and recover transactions lost from the stream with `lastTransactionID`.
- Explain why the `MetaTrader5` package only works on Windows and how to read ticks without losing any when there is no stream.

## 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
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 section[^1][^2][^3][^4]. 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:

```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)
```
```output
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 body[^1]. 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:

```python skip title="alpaca_orden.py"
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:

```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})
```
```output
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.

```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]])
```
```output
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.

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

```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
```
```output
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:

```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"])
```
```output
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.

```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']}")
```
```output
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 way[^2]:

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

```python skip title="tradier_orden.py"
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`).

```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")})
```
```output
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.

```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")))
```
```output
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.

```mermaid
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]
```

```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))
```
```output
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 account[^3]:

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

```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] + "…")
```
```output
[]
['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:

```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__)
```
```output
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 transaction[^3]. 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:

```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()])
```
```output
[(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:

```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)
```
```output
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.* Two rows on a 20-second timeline. Transactions 6409 and 6410 in the first second, 6411 lost at 7.2 and 6412 at 11.6. Heartbeats every 5 seconds with their lastTransactionID: 6410, 6411 (highlighted: greater than the last one seen, 6410, it triggers the sinceid request), 6412 and 6412.

## 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 executable[^4]. 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:

```python skip title="mt5_pendientes.py"
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`:

```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")
```
```output
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):

```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)}")
```
```output
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.* Two rows of marks over the first 10 seconds. Top, the 28 ticks in the interval; bottom, the 18 seen by polling (highlighted). Over the full minute, polling sees 103 of 238 ticks.

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

```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")
```
```output
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.**

```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()
```
```output
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.

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

```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)
```
```output
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.

3. 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).

[^1]: Alpaca, [Create an Order](https://docs.alpaca.markets/reference/postorder) (`CreateOrderRequest` and `Order` schemas), [Get Order by Client Order ID](https://docs.alpaca.markets/reference/getorderbyclientorderid), [Authentication](https://docs.alpaca.markets/docs/authentication), [Paper Trading](https://docs.alpaca.markets/docs/paper-trading) and [WebSocket Streaming](https://docs.alpaca.markets/docs/websocket-streaming); `alpaca-py` 0.44.0 on [PyPI](https://pypi.org/project/alpaca-py/). Accessed on October 22, 2026.
[^2]: Tradier, [Endpoints](https://docs.tradier.com/docs/endpoints), [Place Order](https://docs.tradier.com/reference/brokerage-api-trading-place-order), [Get an order](https://docs.tradier.com/reference/brokerage-api-accounts-get-account-order), [Error responses](https://docs.tradier.com/docs/error-responses), [Response format](https://docs.tradier.com/docs/response-format), [Account streaming responses](https://docs.tradier.com/docs/account-streaming-responses), [Account WebSocket streaming](https://docs.tradier.com/reference/websocket-account-data-streaming), [FAQ](https://docs.tradier.com/docs/faq) and [Rate limiting](https://docs.tradier.com/docs/rate-limiting). Accessed on October 22, 2026.
[^3]: OANDA, [Order Definitions](https://developer.oanda.com/rest-live-v20/order-df/), [Transaction Definitions](https://developer.oanda.com/rest-live-v20/transaction-df/), [Transaction Endpoints](https://developer.oanda.com/rest-live-v20/transaction-ep/) (stream and `sinceid`) and [Development Guide](https://developer.oanda.com/rest-live-v20/development-guide/) (URLs and limits). Accessed on October 22, 2026.
[^4]: [MetaTrader5 on PyPI](https://pypi.org/project/MetaTrader5/) (version 5.0.6231, `win_amd64` wheels only); MQL5, [MetaTrader for Python](https://www.mql5.com/en/docs/python_metatrader5), [initialize](https://www.mql5.com/en/docs/python_metatrader5/mt5initialize_py), [order_send](https://www.mql5.com/en/docs/python_metatrader5/mt5ordersend_py), [order_check](https://www.mql5.com/en/docs/python_metatrader5/mt5ordercheck_py), [copy_ticks_from](https://www.mql5.com/en/docs/python_metatrader5/mt5copyticksfrom_py), [trade operation types](https://www.mql5.com/en/docs/constants/tradingconstants/enum_trade_request_actions) and [return codes](https://www.mql5.com/en/docs/constants/errorswarnings/enum_trade_return_codes). Accessed on October 22, 2026.

## 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?

   - 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`
   - B. It is an integer: it is used as is
   - C. It is a simulator bug
   - D. It depends on the day

   *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?

   - A. You can't
   - B. It is always equal to `avg_fill_price`
   - C. With `avg_fill_price` and `exec_quantity` before and after: (new average × new quantity − previous average × previous quantity) / `last_fill_quantity`
   - D. By requesting the order book

   *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?

   - A. Nothing: heartbeats carry no information
   - B. Closes the account
   - C. Waits for 6412 to arrive on its own
   - D. Requests `GET /v3/accounts/{accountID}/transactions/sinceid?id=6411` to recover the missing ones before continuing

   *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`?

   - A. Because the package is paid
   - 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
   - C. Because it needs Python 2
   - D. Because the broker forbids it

   *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?

   - A. All the ticks that arrive between two queries, except the last; `copy_ticks_from` from the last `time_msc` seen recovers them
   - B. Nothing
   - C. Only the weekend ones
   - D. Pending orders

   *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

### Research

- [Finite-State Machines for Quantitative Trading Execution in Python](https://pythonparatrading.com/en/finite-state-machines-trading-python/) · Research-0029 · 47 min

### Documentation

- [Alpaca: create an order (POST /v2/orders, CreateOrderRequest and Order schemas)](https://docs.alpaca.markets/reference/postorder) · docs.alpaca.markets
- [Alpaca: websocket streaming (trade_updates)](https://docs.alpaca.markets/docs/websocket-streaming) · docs.alpaca.markets
- [Tradier: place an order and account stream responses](https://docs.tradier.com/reference/brokerage-api-trading-place-order) · docs.tradier.com
- [OANDA v20: transaction definitions and transaction endpoints](https://developer.oanda.com/rest-live-v20/transaction-df/) · developer.oanda.com
- [MetaTrader 5 for Python (MQL5)](https://www.mql5.com/en/docs/python_metatrader5) · mql5.com

### Data

- [Level 4 data: sample broker messages (JSON)](https://pythonparatrading.com/curso/datos/nivel-4/brokers4_ejemplos.json) · Download · brokers4_ejemplos.json

Previous lesson: [4.4.2 · Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders](https://pythonparatrading.com/en/python-trading-course/level-4/interactive-brokers-with-ib-async/)

Next lesson: [4.4.4 · Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase](https://pythonparatrading.com/en/python-trading-course/level-4/crypto-exchange-apis-ccxt-and-native/)
