---
title: "4.4.3 Alpaca, Tradier, MetaTrader 5 y OANDA v20"
description: "Cuatro APIs de bróker: entornos de papel y real, órdenes, streams de ejecuciones y sus diferencias, ejecutadas contra un simulador local."
url: "https://pythonparatrading.com/curso-python-trading/nivel-4/alpaca-tradier-metatrader-5-y-oanda/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-4/alpaca-tradier-metatrader-5-y-oanda.png"
---

# 4.4.3 · Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20

> Cuatro brókeres con cuatro maneras de recibir una orden y de contar lo que pasa después. Esta lección reproduce sus mensajes documentados en un simulador local: la misma compra de 434 títulos contada por Alpaca y por Tradier, el stream de transacciones de OANDA con un hueco que hay que rellenar y el sondeo al que obliga MetaTrader 5.

- Curso: [Nivel 4: Carteras, riesgo, ejecución y trading en real](https://pythonparatrading.com/curso-python-trading/nivel-4/)
- Módulo: 4.4 · Brokers y APIs
- Duración: 75 min
- Requisitos: [1.3.6 · Brókeres como fuente de datos](https://pythonparatrading.com/curso-python-trading/nivel-1/brokers-como-fuente-de-datos/), [1.4.1 · Descarga por REST y websockets: paginación, límites y reintentos](https://pythonparatrading.com/curso-python-trading/nivel-1/descarga-rest-websockets-y-reintentos/), [3.3.3 · Riesgo de divisa en carteras multi-moneda; ejecución vía OANDA, IBKR o MT5](https://pythonparatrading.com/curso-python-trading/nivel-3/riesgo-de-divisa-y-ejecucion-fx/), [3.7.4 · ccxt para operar en varios exchanges](https://pythonparatrading.com/curso-python-trading/nivel-3/ccxt-multi-exchange/)

## Al terminar esta lección

- Distinguir la arquitectura de cada API (REST y websocket, formulario y JSON, HTTP por trozos, terminal de Windows) y sus entornos de papel y real.
- Enviar órdenes a Alpaca y Tradier con un identificador propio y reconstruir su estado desde el stream de ejecuciones de cada uno.
- Normalizar ejecuciones de brókeres que dan los números como texto o como número y que no publican los mismos campos.
- Usar los tipos de orden de OANDA v20 con órdenes dependientes y recuperar transacciones perdidas del stream con `lastTransactionID`.
- Explicar por qué el paquete `MetaTrader5` solo funciona en Windows y cómo leer ticks sin perder ninguno sin un stream.

## Cuatro brókeres, cuatro arquitecturas

La lección 4.4.2 trató Interactive Brokers, que pasa por TWS o IB Gateway. Esta trata otros cuatro que se usan mucho
desde Python, cada uno con una arquitectura distinta. La lección 1.3.6 los usó como fuente de datos y la 3.3.3 mandó
órdenes de divisas a OANDA y MetaTrader 5 con reintentos y códigos de retorno; aquí se va a lo que hace falta para
operar un sistema: entornos de papel y real, el stream que cuenta qué pasó con cada orden, sus diferencias de formato
y cómo no perder mensajes.

```mermaid
flowchart TB
  S[Sistema] --> A[Alpaca REST y WS]
  S --> T[Tradier REST y WS]
  S --> O[OANDA REST y HTTP]
  S --> M[Paquete MT5]
  M --> MT[Terminal Windows]
```

| | Alpaca | Tradier | OANDA v20 | MetaTrader 5 |
|---|---|---|---|---|
| Transporte | REST JSON; websocket para `trade_updates` | REST con formulario; respuesta JSON si se pide (por defecto XML); websocket de cuenta (beta) | REST JSON; streams por HTTP por trozos | Llamadas a un terminal local por comunicación entre procesos |
| Autenticación | Cabeceras `APCA-API-KEY-ID` y `APCA-API-SECRET-KEY` | `Authorization: Bearer` | `Authorization: Bearer` | Usuario, contraseña y servidor en `initialize` |
| Papel y real | `paper-api.alpaca.markets` y `api.alpaca.markets`, con claves distintas | `sandbox.tradier.com` y `api.tradier.com`; el sandbox tiene datos retrasados 15 minutos | `api-fxpractice.oanda.com` y `api-fxtrade.oanda.com`; streams en `stream-…` | Cuenta demo o real del bróker, en el mismo terminal |
| Números | Texto (`"434"`, `"234.8353"`) | Números JSON | Texto | `float` de Python |
| Identificador propio | `client_order_id` (hasta 128 caracteres) | `tag` | `clientExtensions.id` | `magic` y `comment` |

Las fuentes de cada fila están en las secciones de cada bróker[^1][^2][^3][^4]. Las tres primeras son APIs de red:
el código puede correr en cualquier sistema operativo. MetaTrader 5 no: el paquete de Python habla con un programa
de Windows que tiene que estar instalado y abierto.

### Papel y real: la misma API, otra URL y otras claves

En Alpaca, la documentación de autenticación es explícita: las cuentas de papel y real tienen credenciales separadas
y «no puedes usar las credenciales de la cuenta real con la API de papel, ni al revés»[^1]. Que la clave equivocada
dé un error 401 es una protección, pero no la única que hace falta: el error peligroso es tener las dos claves
configuradas y apuntar a la URL real creyendo estar en papel. La primera celda elige la URL a partir del modo y exige
una confirmación explícita para el modo real, con la misma regla de la lección 0.2.4 para leer un booleano del
entorno:

```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}: modo real sin PPT_REAL=si en el entorno")
    return URLS[broker][modo]

for b in URLS:
    print(f"{b:<8} papel -> {url_base(b, 'papel')}")
try:
    url_base("alpaca", "real")
except ModoNoPermitido as e:
    print("bloqueado:", e)
```
```output
alpaca   papel -> https://paper-api.alpaca.markets
tradier  papel -> https://sandbox.tradier.com/v1
oanda    papel -> https://api-fxpractice.oanda.com
bloqueado: alpaca: modo real sin PPT_REAL=si en el entorno
```

Por defecto, todo va a papel; para operar en real hay que decirlo en el entorno de la ejecución, no en el código.
Las claves de cada modo se guardan aparte y con permisos distintos (lección 4.4.5).

## Alpaca: órdenes por REST y ejecuciones por websocket

Una orden en Alpaca es un `POST /v2/orders` con un cuerpo JSON[^1]. Según el esquema `CreateOrderRequest` de la
documentación:

- `type` puede ser `market`, `limit`, `stop`, `stop_limit` o `trailing_stop` (en acciones; en cripto, solo `market`,
  `limit` y `stop_limit`), y `time_in_force`, `day`, `gtc`, `opg` (apertura), `cls` (cierre), `ioc` o `fok`.
- `qty` y `notional` (importe en dólares, solo con órdenes a mercado y `day`) se excluyen entre sí. Las cantidades y
  los precios se mandan y se reciben **como texto**.
- `client_order_id` es el identificador propio, de hasta 128 caracteres; si no se manda, Alpaca genera uno. La orden
  se consulta después por él en `GET /v2/orders:by_client_order_id?client_order_id=…`.
- `order_class` permite órdenes `bracket`, `oco` y `oto` con `take_profit` y `stop_loss`.
- Las respuestas de error documentadas para el `POST` son 403 («Buying power or shares is not sufficient») y 422
  («Input parameters are not recognized»).

Con el SDK oficial, `alpaca-py` (versión 0.44.0 en PyPI, de agosto de 2026), el envío y la escucha del stream son
así; no está en el entorno del curso y necesita una cuenta, así que no se ejecuta:

```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):               # el manejador es async
    print(evento.event, evento.order.filled_qty, evento.price)

stream = TradingStream(clave, secreto, paper=True)
stream.subscribe_trade_updates(al_recibir)
stream.run()                                # bloquea: bucle propio
```

El stream `trade_updates` vive en `wss://paper-api.alpaca.markets/stream` (papel) o `wss://api.alpaca.markets/stream`
(real). La documentación de streaming describe el protocolo: un mensaje `{"action": "auth", "key": …, "secret": …}`
que se contesta con `{"stream": "authorization", "data": {"status": "authorized", …}}`, otro
`{"action": "listen", "data": {"streams": ["trade_updates"]}}` y, a partir de ahí, un mensaje por evento con el
campo `event` y la orden completa en `order`. Los eventos habituales son `new`, `fill`, `partial_fill`, `canceled`,
`expired`, `done_for_day` y `replaced`; en las ejecuciones llegan además `price`, `qty`, `position_qty` y
`timestamp`, todos como texto. Dos detalles de la documentación: el stream de papel usa tramas binarias, no de
texto, y el SDK se autentica con otra forma de mensaje (`"action": "authenticate"` con `key_id` y `secret_key`).
Lo segundo es lo que hace el código del SDK; la documentación solo muestra la primera forma.

### El ejemplo del curso, contra el esquema documentado

El fichero `brokers4_ejemplos.json` del nivel 4 cuenta una orden del sistema en vivo de los datos (la `R00005`, una
compra a mercado de 434 ORSC con tres ejecuciones) con la forma de los mensajes de cada bróker. Se escribió como
ejemplo y hay que comprobarlo contra la documentación antes de usarlo. La celda compara sus campos con la lista de
propiedades del esquema `Order` de Alpaca, copiada de la documentación de `POST /v2/orders`:

```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("en el ejemplo y no en el esquema:", set(final) - ORDER_DOC)
print("en el esquema y no en el ejemplo:",
      sorted(ORDER_DOC - set(final)))
tipos = {type(final[k]).__name__ for k in
         ("qty", "filled_qty", "filled_avg_price")}
print("tipo de qty, filled_qty y 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
en el ejemplo y no en el esquema: set()
en el esquema y no en el ejemplo: ['expires_at', 'group_id', 'group_role', 'ratio_qty']
tipo de qty, filled_qty y 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'}
```

Todos los campos del ejemplo existen en el esquema y los números van como texto, como en la documentación. Faltan
cuatro campos opcionales (de órdenes con vencimiento y de órdenes de varias patas), que no se usan aquí. Uno de los
que sí están, `order_type`, el esquema lo marca como obsoleto en favor de `type`: el código nuevo debe leer `type`.

### Un bróker simulado con dos caras

El simulador de esta lección tiene un núcleo (órdenes, ejecuciones, suscriptores) y dos fachadas que hablan como
Alpaca y como Tradier. Funciona en el mismo bucle de eventos de la lección, con `aiohttp` (llega al entorno del
curso como dependencia de ccxt) para el servidor HTTP y los websockets, en un puerto que elige el sistema operativo.
Para que la lección dé siempre el mismo resultado, con cualquier carga de la máquina, no hay reloj real: las horas
de cada evento salen del guion de la orden, y las ejecuciones de `R00005` son las del fichero de ejemplo.

```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:
    """Órdenes y ejecuciones; publica cada evento a los suscriptores.
    Sin reloj: las horas salen del guion de cada orden."""

    def __init__(self, poder_compra, guiones):
        self.poder_compra = poder_compra
        self.guiones = guiones    # cid -> (alta, envío, ejecuciones)
        self.ordenes, self.por_cid = {}, {}
        self.subs = {}                    # cuenta -> colas asyncio
        self.posicion = {}                # (cuenta, símbolo) -> títulos

    def suscribir(self, cuenta):
        cola = asyncio.Queue()
        self.subs.setdefault(cuenta, []).append(cola)
        return cola

    async def cerrar(self):
        """Avisa a los suscriptores de que el servidor se apaga."""
        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))  # copia
        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("alta y envío:", guion_r5[0][11:], guion_r5[1][11:])
print("ejecuciones del guion:", [(q, px) for _, q, px, _ in
                                 guion_r5[2]])
```
```output
alta y envío: 13:35:00.805771Z 13:35:00.835677Z
ejecuciones del guion: [(27, 234.64), (389, 234.85), (18, 234.81)]
```

La fachada de Alpaca traduce el núcleo a su forma: valida el cuerpo del `POST` contra las listas de valores del
esquema, comprueba las cabeceras, contesta con la orden y publica cada evento en el websocket con la orden completa
dentro. Dos decisiones son del simulador, no de la documentación: el código numérico de error del 403 (la
documentación da el texto, no el código) y que un `client_order_id` repetido se rechace con un 422. La documentación
dice que el identificador es único; qué devuelve exactamente ante uno repetido hay que comprobarlo en la cuenta de
papel.

```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}             # para el poder de compra

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")     # antes de confirmar
    await ws.send_json({"stream": "listening",
                        "data": pedido["data"]})
    try:
        await emitir_alpaca(ws, cola)
    except ConnectionResetError:          # el cliente se fue
        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})
```

El cliente hace lo que haría con Alpaca: abre el websocket, se autentica, se suscribe y solo entonces envía la orden,
para no perder ningún evento. Después lee eventos hasta un estado final y reconstruye la orden con lo que llega:
cantidad acumulada, efectivo y precio medio.

```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" ejecutadas {acum:>3}  media "
          f"{efectivo / acum if acum else 0:.4f}"
          f"  (bróker: {d['order']['filled_avg_price']})")
    if d["order"]["status"] in FINALES:
        break
```
```output
authorized
listening
POST: 200 new
13:35:00.835 new           ejecutadas   0  media 0.0000  (bróker: None)
13:35:01.024 partial_fill  ejecutadas  27  media 234.6400  (bróker: 234.6400)
13:35:02.522 partial_fill  ejecutadas 416  media 234.8364  (bróker: 234.8364)
13:35:04.441 fill          ejecutadas 434  media 234.8353  (bróker: 234.8353)
```

Los cuatro eventos son los del ejemplo: `new` a las 13:35:00.835 y tres ejecuciones de 27, 389 y 18 títulos en 3,6
segundos. El precio medio que calcula el cliente con las ejecuciones coincide en cada paso con el `filled_avg_price`
que manda el bróker, que es la comprobación que debe hacer un sistema: si no cuadran, falta un mensaje. La figura de
la cabecera dibuja esta orden: la cantidad acumulada sube en tres escalones y el precio medio final, 234,8353, queda
céntimo y medio por debajo del precio de la segunda ejecución, que es la grande.

Lo último es comparar la orden reconstruida con la que devuelve la consulta por `client_order_id` y con la orden
final del fichero de ejemplo:

```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), "campos comunes; distintos:", distintos)
print("reconstruida:", acum, f"{efectivo / acum:.4f}")
print("consultada:  ", consultada["filled_qty"],
      consultada["filled_avg_price"], consultada["status"])
```
```output
14 campos comunes; distintos: []
reconstruida: 434 234.8353
consultada:   434 234.8353 filled
```

Los 14 campos que produce el simulador coinciden con los del ejemplo, y la orden reconstruida desde el stream, con la
consultada. En un sistema real, esta consulta es la que se hace al reconectar el websocket: los eventos que se
emitieron mientras estaba caído no se vuelven a mandar, y el estado de la orden se recupera por REST (lección 4.5.2).

### Los errores que devuelve el POST

Tres peticiones malas: un `time_in_force` que no existe (`gtd`, que sí existe en OANDA), una orden que supera el
poder de compra y el mismo `client_order_id` otra vez.

```python
malas = {
    "tif gtd": cuerpo | {"client_order_id": "R9001",
                         "time_in_force": "gtd"},
    "10 000 títulos": cuerpo | {"client_order_id": "R9002",
                               "qty": "10000"},
    "R00005 otra vez": 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 títulos   403 insufficient buying power
R00005 otra vez  422 client_order_id must be unique
```

El 422 y el 403 son errores definitivos: reintentar la misma orden da el mismo resultado. Con 10.000 títulos a unos
234,80 USD la orden pide 2,35 millones y la cuenta tiene un millón de poder de compra. El tercero es el que protege
de las órdenes duplicadas tras un timeout (lección 3.7.4): el bróker reconoce el identificador y no crea otra orden.

## Tradier: formulario, JSON y un stream de cuenta

Tradier tiene otra forma de trabajar[^2]:

- La orden se manda como **formulario** (`application/x-www-form-urlencoded`) a
  `POST /v1/accounts/{account_id}/orders`, con `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` y `tag`.
- La respuesta por defecto es **XML**; para JSON hay que pedirla con `Accept: application/json`. La guía de formato
  avisa de que una lista puede llegar como objeto o como lista según el número de elementos.
- El `POST` devuelve `{"order": {"id": …, "status": "ok", …}}`. La página de errores avisa de que, ante un error de
  los sistemas que hay detrás, el `POST` puede devolver un 200: hay que consultar la orden y mirar su estado y su
  propiedad `errors`.
- La consulta (`GET /v1/accounts/{account_id}/orders/{id}`) da los números como **números JSON**: `quantity`,
  `avg_fill_price`, `exec_quantity`, `last_fill_price`, `last_fill_quantity`, `remaining_quantity`.
- El stream de cuenta se abre en dos pasos: `POST /v1/accounts/events/session` devuelve una `sessionid` que hay que
  usar en menos de 5 minutos, y en `wss://ws.tradier.com/v1/accounts/events` se manda
  `{"events": ["order"], "sessionid": …, "excludeAccounts": []}`. La documentación lo marca como beta.
- El sandbox (`sandbox.tradier.com`) es una cuenta de papel con datos retrasados 15 minutos. Sobre el stream en papel
  la documentación se contradice: las preguntas frecuentes dicen que no hay stream para papel y la página del stream
  de cuenta da un servidor `sandbox-ws.tradier.com`. Hay que probarlo antes de diseñar sobre ello.
- Límites por minuto y por token: 60 peticiones de órdenes por minuto en producción y en sandbox, 120 de consulta en
  producción y 60 en 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"        # papel
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"])
```

### La misma orden, contada por Tradier

El fichero de ejemplo trae la consulta de `R00005` al terminar con la forma de Tradier. Sus campos están en el ejemplo
de la documentación de `GET …/orders/{id}`, con los números como números. La segunda fachada del simulador usa el
mismo núcleo: el `POST` con formulario, la consulta y el stream de cuenta. Los eventos `order` del stream llevan los
campos de la página de respuestas del stream de cuenta: `status`, `avg_fill_price`, `exec_quantity`,
`last_fill_quantity`, `remaining_quantity`, `transaction_date`… y no `last_fill_price`, que esa página no lista
(la página de referencia del stream, más antigua, usa además `executed_quantity` en lugar de `exec_quantity`).

```python
TOKEN_TRADIER = "FALSA-TOKEN-SANDBOX"
EJ_T = EJ["tradier"]["order"]
nucleo.guiones["R00006"] = guion_r5      # la misma orden, otra cuenta
IDS_T = {}                               # id del núcleo -> id Tradier

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()                  # formulario, no 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("ejemplo frente a la documentación:", set(EJ_T) ^ GET_DOC)
print("tipos:", {type(EJ_T[k]).__name__ for k in
                 ("quantity", "avg_fill_price", "exec_quantity")})
```
```output
ejemplo frente a la documentación: set()
tipos: {'float'}
```

El cliente de Tradier pide la sesión, abre el websocket, manda el formulario y escucha. Como el stream no trae el
precio de la última ejecución, lo deduce: el efectivo acumulado es `avg_fill_price × exec_quantity`, y la diferencia
entre dos eventos, dividida por `last_fill_quantity`, es el precio de lo último que se ejecutó.

```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("primer mensaje:", (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"  media {e['avg_fill_price']:<9}"
          f"  último precio deducido {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("iguales al ejemplo:", 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
primer mensaje: heartbeat
POST: {'id': 228005, 'status': 'ok'}
open              exec   0.0  media 0.0        último precio deducido None
partially_filled  exec  27.0  media 234.64     último precio deducido 234.64
partially_filled  exec 416.0  media 234.8364   último precio deducido 234.85
filled            exec 434.0  media 234.8353   último precio deducido 234.8099
iguales al ejemplo: True
```

Los precios deducidos son 234,64, 234,85 y 234,8099: los de las tres ejecuciones salvo el redondeo, porque la media
llega con cuatro decimales y su error se arrastra a las ejecuciones siguientes. Con una ejecución grande detrás de una
pequeña ese error es diminuto; con muchas ejecuciones pequeñas puede pasar de un tick. Por eso, si el precio exacto
de cada ejecución importa (para la comisión, el deslizamiento o la conciliación con el extracto), se pide al bróker
el detalle de ejecuciones en lugar de deducirlo. La consulta final coincide con el ejemplo del fichero en los diez
campos comparados.

## Un formato interno para todos

El resto del sistema (posiciones, riesgo, conciliación) no debería saber de qué bróker viene una ejecución. Un
adaptador por bróker convierte cada mensaje a una estructura interna con tipos fijos: cantidades enteras o
`Decimal`, precios `Decimal`, horas en UTC y un estado de una lista cerrada. Los estados de cada bróker se traducen
a esa lista; la máquina de estados de la orden es la de la lección 4.5.2.

```mermaid
flowchart LR
  A[trade_updates] --> N[Normalizador]
  T[Eventos Tradier] --> N
  O[Transacciones OANDA] --> N
  N --> E[Ejecución interna]
  E --> P[Posiciones]
  E --> C[Conciliación]
```

```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')
```

Los dos mensajes dicen lo mismo con tipos distintos: `"18"` y `"234.81"` como texto en Alpaca; `18.0` y una media de
la que se deduce `234.8099` en Tradier. El normalizador cuantiza el precio deducido al tick de 0,01 (que es una
decisión: la deducción no da el precio exacto) y entrega dos ejecuciones iguales salvo el bróker y el identificador
de la orden. `Decimal` en lugar de `float` evita que 0,1 + 0,2 descuadre una conciliación al céntimo.

## OANDA v20: tipos de orden y el stream de transacciones

La lección 3.3.3 envió órdenes a mercado a OANDA con `priceBound` y consulta por identificador propio. La v20 tiene
más tipos de orden y un stream que cuenta todo lo que pasa en la cuenta[^3]:

| Tipo (`type`) | Qué hace | `timeInForce` admitidos |
|---|---|---|
| `MARKET` | Ejecuta al precio de mercado, con `priceBound` opcional | `FOK` (por defecto) o `IOC` |
| `LIMIT` | Compra a ese precio o mejor | `GTC`, `GTD`, `GFD`, `FOK`, `IOC` |
| `STOP` | Se activa al tocar el precio y ejecuta como mercado | Los mismos |
| `MARKET_IF_TOUCHED` | Como `LIMIT` pero activada al tocar, sin importar desde dónde | Los mismos |
| `TAKE_PROFIT`, `STOP_LOSS`, `GUARANTEED_STOP_LOSS`, `TRAILING_STOP_LOSS` | Órdenes que cierran una operación (`tradeID`) | Según el tipo |

Las cuatro últimas se pueden pedir junto a la orden que abre la posición, con `takeProfitOnFill`, `stopLossOnFill`,
`guaranteedStopLossOnFill` y `trailingStopLossOnFill`: cuando la orden se ejecuta, OANDA crea las órdenes de cierre
ligadas a la operación nueva. `GTD` exige `gtdTime`; `GFD` vence a las 17:00 de Nueva York. Un validador local evita
mandar cuerpos que el servidor rechazará:

```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']} no admite {o['timeInForce']}")
    if o["timeInForce"] == "GTD" and "gtdTime" not in o:
        errores.append("GTD sin gtdTime")
    if o["type"] != "MARKET" and "price" not in o:
        errores.append("falta price")
    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 del lado equivocado")
    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 no admite GTD']
['stop del lado equivocado']
{"order": {"type": "LIMIT", "instrument": "EUR_USD", "units": "10000", "…
```

La primera orden pasa. Convertida en orden a mercado con `GTD`, el validador la para (una orden a mercado solo admite
`FOK` o `IOC`); con el stop por encima del precio de compra, también. El cuerpo final va dentro de un campo `order`,
como en la lección 3.3.3.

### La transacción de ejecución, contra su definición

Cuando la orden se ejecuta, OANDA crea una transacción `ORDER_FILL`. El fichero de ejemplo trae una; la celda la
compara con la lista de campos de `OrderFillTransaction` en la documentación de definiciones de transacciones:

```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("no documentados:", set(tx) - FILL_DOC)
print("obsoletos usados:", sorted(OBSOLETOS & set(tx)))
print("precio oficial (tradeOpened):", tx["tradeOpened"]["price"],
      "| fullVWAP:", tx["fullVWAP"], "| tipo:",
      type(tx["units"]).__name__)
```
```output
no documentados: {'requestedUnits'}
obsoletos usados: ['gainQuoteHomeConversionFactor', 'lossQuoteHomeConversionFactor', 'price']
precio oficial (tradeOpened): 1.16451 | fullVWAP: 1.16451 | tipo: str
```

El ejemplo tiene un campo que la definición no recoge, `requestedUnits`, y tres que la documentación marca como
obsoletos: `price` («los campos `tradesClosed`, `tradeReduced` y `tradeOpened` contienen el precio exacto y oficial»)
y los dos factores de conversión, que «se eliminarán». Un parser que dependa de ellos se romperá algún día; el código
de esta lección lee el precio de `tradeOpened` y no usa `requestedUnits`.

### Un stream de transacciones con un hueco

El stream de transacciones (`GET /v3/accounts/{accountID}/transactions/stream`, en los servidores `stream-…`) es una
respuesta HTTP que no termina: codificación por trozos, un objeto JSON por línea y un latido `HEARTBEAT` cada 5
segundos con `lastTransactionID`, el identificador de la última transacción de la cuenta[^3]. Ese campo convierte
el latido en un control: si es mayor que la última transacción vista, se ha perdido algo, y
`GET /v3/accounts/{accountID}/transactions/sinceid?id=…` devuelve las transacciones posteriores a un identificador.
La lección 1.4.1 detectaba huecos por número de secuencia; aquí el hueco lo delata el latido, aunque no llegue
ningún mensaje después del perdido.

El servidor local emite, con horas de un reloj lógico, la orden y la ejecución del ejemplo, latidos cada 5 segundos
y dos transacciones más, de las que «pierde» la primera:

```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')]
```

El cliente lee línea a línea. Cada transacción actualiza la última vista; cada latido se compara con ella, y si el
latido va por delante, el cliente pide lo que falta antes de seguir:

```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("  repetida", m["id"],
                      "por el stream: descartada")
                continue
            ultima = int(m["id"])
            vistas.append(m["id"])
            continue
        if int(m["lastTransactionID"]) > ultima:
            print(f"{m['time'][11:19]} latido {m['lastTransactionID']}"
                  f" > última vista {ultima}: pido 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("  recuperada", t["id"], t["type"])
                    ultima = max(ultima, int(t["id"]))
print("transacciones procesadas:", vistas)
```
```output
13:35:10 latido 6411 > última vista 6410: pido sinceid
  recuperada 6411 STOP_LOSS_ORDER
  recuperada 6412 ORDER_CANCEL
  repetida 6412 por el stream: descartada
transacciones procesadas: ['6409', '6410', '6411', '6412']
```

La 6411 (la creación del stop) se pierde a los 7,2 segundos. El latido de los 10 segundos dice que la última
transacción de la cuenta es la 6411 y el cliente solo ha visto la 6410: pide `sinceid` con 6410 y recupera la 6411.
El servidor simulado contesta con el estado completo de la cuenta, así que devuelve también la 6412, que el stream
aún no había entregado; cuando llega por el stream, el cliente la descarta por repetida. Es lo que pasa con un
bróker real: la consulta por REST y el stream no van sincronizados, y el procesado tiene que ser idempotente por
identificador de transacción. Sin el control del latido, el sistema no sabría que existe un
stop en la cuenta. La figura muestra la línea de tiempo: transacciones arriba, latidos abajo y, en ámbar, el latido
que detecta el hueco.

*Fig. — Stream de transacciones de OANDA simulado: el latido de los 10 segundos delata la transacción perdida.* Dos filas en una línea de tiempo de 20 segundos. Transacciones 6409 y 6410 en el primer segundo, 6411 perdida a los 7,2 y 6412 a los 11,6. Latidos cada 5 segundos con su lastTransactionID: 6410, 6411 (destacado: mayor que la última vista, 6410, y dispara la consulta sinceid), 6412 y 6412.

## MetaTrader 5: solo Windows y sin stream

El paquete de Python de MetaTrader 5 (`MetaTrader5`, versión 5.0.6231 en PyPI, de septiembre de 2026) solo publica
ruedas `win_amd64`: no hay versión para Linux ni para macOS, ni código fuente que compilar. Su descripción en PyPI
es «API Connector to MetaTrader 5 Terminal», y la documentación de MQL5 explica que obtiene los datos «por
comunicación entre procesos directamente desde el terminal MetaTrader 5»; `initialize()` arranca el terminal si
hace falta, y su parámetro `path` es la ruta al ejecutable del terminal[^4]. En la práctica, un sistema con MetaTrader 5 se
ejecuta en una máquina Windows con el terminal abierto y la sesión del bróker iniciada; el resto del sistema puede
vivir en Linux y hablar con ese proceso por la red, con el aislamiento que se verá en la lección 4.5.1.

La lección 3.3.3 mandó órdenes a mercado (`TRADE_ACTION_DEAL`) y clasificó los códigos de retorno. Para un sistema
hacen falta además las órdenes pendientes, la comprobación previa y saber cuáles son las órdenes propias:

```python skip title="mt5_pendientes.py"
import MetaTrader5 as mt5                      # solo Windows

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   # papel
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,                  # segundos Unix
    "type_filling": mt5.ORDER_FILLING_RETURN,
    "magic": 418272, "comment": "R4-fx-0001",
}
chequeo = mt5.order_check(peticion)            # margen, sin enviar
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]               # solo las del sistema
posiciones = mt5.positions_get(symbol="EURUSD")
mt5.shutdown()
```

- `TRADE_ACTION_PENDING` coloca una orden pendiente (`ORDER_TYPE_BUY_LIMIT`, `SELL_LIMIT`, `BUY_STOP`, `SELL_STOP`,
  `BUY_STOP_LIMIT`, `SELL_STOP_LIMIT`); `TRADE_ACTION_SLTP` cambia el stop y el objetivo de una posición abierta;
  `TRADE_ACTION_MODIFY` y `TRADE_ACTION_REMOVE` modifican o retiran una pendiente.
- `order_check` comprueba fondos y margen y devuelve un `MqlTradeCheckResult`; la documentación avisa de que una
  comprobación correcta no garantiza que la operación se ejecute.
- `magic` es el número del sistema que envía: con él se separan las órdenes y posiciones propias de las que abre el
  usuario a mano en el mismo terminal.
- `account_info().trade_mode` dice si la cuenta es demo (`ACCOUNT_TRADE_MODE_DEMO`), de concurso o real: es la
  comprobación de papel o real de MetaTrader 5, porque la URL no cambia.

El resultado de `order_send` del fichero de ejemplo (`metatrader5.resultado`) tiene los campos de la documentación de
`order_send` y de `MqlTradeResult`, y su `request`, los de la estructura de la petición. Los valores numéricos de las
constantes no están en las páginas de MQL5 (solo sus nombres); salen del propio paquete, que los define en
`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("acción:", nombre[res["request"]["action"]],
      "| tipo compra:",
      res["request"]["type"] == CONST["ORDER_TYPE_BUY"],
      "| relleno IOC:",
      res["request"]["type_filling"] == CONST["ORDER_FILLING_IOC"],
      "| hecho:", res["retcode"] == CONST["TRADE_RETCODE_DONE"])
print(f"precio {res['price']} = ask {res['ask']}; horquilla "
      f"{(res['ask'] - res['bid']) * 1e5:.1f} puntos")
```
```output
True True
acción: TRADE_ACTION_DEAL | tipo compra: True | relleno IOC: True | hecho: True
precio 1.16452 = ask 1.16452; horquilla 8.0 puntos
```

Los campos coinciden con la documentación y los códigos con las constantes del paquete: una compra a mercado
(`TRADE_ACTION_DEAL`, `ORDER_TYPE_BUY`) con relleno IOC, ejecutada (`10009`) al ask.

### Sin stream: sondear sin perder ticks

La lista de funciones del paquete no tiene suscripciones ni callbacks: los datos se piden (`symbol_info_tick`,
`copy_ticks_from`, `market_book_get`), no llegan solos. Es una deducción de la documentación, que lista funciones y
no dice «no hay stream» en ninguna parte, pero tiene una consecuencia práctica: un sistema que consulta
`symbol_info_tick` cada medio segundo solo ve el último tick de cada intervalo. `copy_ticks_from(símbolo, desde,
cuántos, banderas)` devuelve los ticks a partir de una hora, con `time_msc` en milisegundos; pidiendo desde el último
`time_msc` visto no se pierde ninguno.

La celda emula las dos formas con un minuto de ticks sintéticos (llegadas de Poisson, unos cuatro por segundo, con
semilla fija):

```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 del minuto: {len(ticks)}")
print(f"sondeo de symbol_info_tick cada 500 ms: {len(vistos_sondeo)}")
print(f"copy_ticks_from desde el último time_msc: {len(vistos_copia)}")
```
```output
ticks del minuto: 238
sondeo de symbol_info_tick cada 500 ms: 103
copy_ticks_from desde el último time_msc: 238
```

El sondeo ve 103 de 238 ticks: menos de la mitad. Pedir desde el último `time_msc` los ve todos. Hay un detalle en
el código: `copy_ticks_from` incluye los ticks con la misma hora que `desde`, así que hay que filtrar los ya vistos
(`> ultimo`); si dos ticks comparten milisegundo, el filtro por hora no basta y hay que comparar también el contenido.

*Fig. — Diez segundos de ticks sintéticos: todos, como los devuelve copy_ticks_from, y los que ve un sondeo de symbol_info_tick cada 500 ms.* Dos filas de marcas en los 10 primeros segundos. Arriba, los 28 ticks del intervalo; abajo, los 18 que ve el sondeo (destacados). En el minuto completo, el sondeo ve 103 de 238 ticks.

## Lo que sale mal con estas APIs

- **Papel y real mezclados.** La URL real con una clave de papel da 401; la URL real con la clave real, cuando se
  creía estar en papel, da órdenes de verdad. El modo se decide en el entorno de ejecución y se comprueba (URL en
  Alpaca, Tradier y OANDA; `trade_mode` en MetaTrader 5).
- **Tipos de los números.** Texto en Alpaca y OANDA, números en Tradier, `float` en MetaTrader 5. Sin normalizar,
  `"27" + "389"` es `"27389"`.
- **Campos que cambian.** `order_type` obsoleto en Alpaca; `price` y los factores de conversión obsoletos en OANDA;
  `exec_quantity` y `executed_quantity` en dos páginas de Tradier. El adaptador debe fallar de forma ruidosa si
  falta un campo que necesita, no rellenarlo con cero.
- **Mensajes perdidos.** Un websocket que se reconecta no reenvía lo que pasó mientras estaba caído: al reconectar,
  se consulta el estado de las órdenes abiertas por REST; en OANDA, `sinceid` desde la última transacción vista.
- **Errores con 200.** Tradier puede devolver 200 al `POST` con la orden rechazada después: la respuesta del `POST`
  no es la confirmación, lo es el estado de la orden.
- **Límites de ritmo.** 60 órdenes por minuto en Tradier, 120 peticiones por segundo por IP en OANDA, 429 en
  Alpaca con las cabeceras `X-RateLimit-*`. Un bucle de reintentos sin espera los agota (lección 1.4.1).
- **Windows obligatorio para MetaTrader 5**, y un terminal que hay que vigilar como un proceso más: si se cierra o
  pierde la sesión, `initialize` falla y el sistema tiene que saberlo.

```python
await nucleo.cerrar()          # el servidor suelta los websockets
await ws.close()
await ws_t.close()
await sesion.close()
for c in (corredor, corredor_t, corredor_o):
    await c.cleanup()
print("simuladores cerrados")
```
```output
simuladores cerrados
```

## Ejercicios

1. Añade al núcleo y a la fachada de Alpaca una orden límite que no se ejecuta y se cancela con
   `DELETE /v2/orders/{order_id}`: el stream debe emitir `new` y después `canceled`, y la consulta final debe dar
   `status` `canceled` con `filled_qty` `"0"`. Usa un guion sin ejecuciones.

**Solución.**

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

El simulador reutiliza el identificador de orden del ejemplo para cualquier orden de Alpaca (una simplificación:
en Alpaca cada orden tiene el suyo). La cancelación emite `canceled` con `filled_qty` `"0"`. Con una orden real hay
dos cosas más: entre la petición y la confirmación puede llegar una ejecución (el evento `pending_cancel` existe por
eso), y el `DELETE` puede fallar si la orden ya está en un estado final. El estado lo dice siempre el stream o la
consulta, no la respuesta del `DELETE`.

2. Con los eventos de Tradier, la deducción del precio de cada ejecución acumula el error de redondeo de la media.
   Calcula ese error para R00006 comparando cada precio deducido con el de la ejecución verdadera del guion.

**Solución.**

```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)       # lo que publica Tradier
    caja = media * acum_q
    deducido = (caja - previo) / q
    previo = caja
    errores.append(round(deducido - px, 6))
print("error del precio deducido por ejecución:", errores)
```
```output
error del precio deducido por ejecución: [0.0, 3.2e-05, -0.000122]
```

La primera ejecución sale exacta y las otras dos se desvían en 0,000032 y −0,000122 USD, alrededor de una
centésima de céntimo. El error de redondeo de la media (hasta 0,00005) se multiplica por la cantidad acumulada y se
divide por la de la última ejecución: con 18 títulos detrás de 416, el factor es 434/18 ≈ 24. Es pequeño frente al
tick de 0,01, pero no es cero y crece con muchas ejecuciones pequeñas: para conciliar al céntimo con el extracto hay
que pedir las ejecuciones con su precio, no deducirlo.

3. El sistema del proyecto del nivel tiene que funcionar con Alpaca en papel y, si algún día se usa MetaTrader 5, con
   un bróker de divisas. Describe cómo organizarías el código para que el resto del sistema no dependa de ninguno de
   los dos ni de que MetaTrader 5 solo funcione en Windows.

**Solución.**

Un adaptador por bróker con la misma interfaz (enviar, cancelar, consultar por identificador propio, eventos de
ejecución en el formato interno de la sección de normalización) y el resto del sistema hablando solo con esa
interfaz. El adaptador de Alpaca es un cliente REST y websocket que puede correr en cualquier sitio. El de MetaTrader
5 se parte en dos: un proceso pequeño en Windows, junto al terminal, que llama al paquete `MetaTrader5`, sondea los
ticks y las órdenes desde el último visto y publica eventos; y un cliente en el sistema principal que habla con ese
proceso por la red. El proceso de Windows no toma decisiones: traduce. Así el sistema no importa `MetaTrader5`, los
tests usan un simulador como el de esta lección y cambiar de bróker es cambiar de adaptador. La elección del modo
(papel o real) sigue siendo del entorno, y cada adaptador comprueba al arrancar que el modo pedido es el de la cuenta
(URL en Alpaca, `trade_mode` en MetaTrader 5).

[^1]: Alpaca, [Create an Order](https://docs.alpaca.markets/reference/postorder) (esquemas `CreateOrderRequest` y `Order`), [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) y [WebSocket Streaming](https://docs.alpaca.markets/docs/websocket-streaming); `alpaca-py` 0.44.0 en [PyPI](https://pypi.org/project/alpaca-py/). Consultadas el 22/10/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) y [Rate limiting](https://docs.tradier.com/docs/rate-limiting). Consultadas el 22/10/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 y `sinceid`) y [Development Guide](https://developer.oanda.com/rest-live-v20/development-guide/) (URLs y límites). Consultadas el 22/10/2026.
[^4]: [MetaTrader5 en PyPI](https://pypi.org/project/MetaTrader5/) (versión 5.0.6231, solo ruedas `win_amd64`); MQL5, [MetaTrader para 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), [tipos de operación](https://www.mql5.com/en/docs/constants/tradingconstants/enum_trade_request_actions) y [códigos de retorno](https://www.mql5.com/en/docs/constants/errorswarnings/enum_trade_return_codes). Consultadas el 22/10/2026.

## Autoevaluación

1. Una orden de Alpaca enviada con `qty` 434 llega al stream con `"filled_qty": "416"`. ¿Qué tipo tiene ese valor en el JSON y qué hay que hacer con él?

   - A. Es texto: hay que convertirlo (a `Decimal` o `float`) antes de operar con él, igual que `price`, `qty` y `filled_avg_price`
   - B. Es un número entero: se usa tal cual
   - C. Es un error del simulador
   - D. Depende del día

   *Respuesta A: Es texto: hay que convertirlo (a `Decimal` o `float`) antes de operar con él, igual que `price`, `qty` y `filled_avg_price`.* El esquema `Order` de Alpaca declara las cantidades y los precios como `string`. Tradier, en cambio, los da como números JSON. Un normalizador convierte los dos al mismo tipo antes de que lleguen al resto del sistema.

2. El stream de cuenta de Tradier no publica `last_fill_price`. ¿Cómo se obtiene el precio de la última ejecución?

   - A. No se puede
   - B. Es siempre igual a `avg_fill_price`
   - C. Con `avg_fill_price` y `exec_quantity` antes y después: (media nueva × cantidad nueva − media anterior × cantidad anterior) / `last_fill_quantity`
   - D. Pidiendo el libro de órdenes

   *Respuesta C: Con `avg_fill_price` y `exec_quantity` antes y después: (media nueva × cantidad nueva − media anterior × cantidad anterior) / `last_fill_quantity`.* El efectivo acumulado es media × cantidad; la diferencia entre dos eventos es el efectivo de la última ejecución. En la lección salen 234,85 y 234,8099 para las dos últimas ejecuciones de R00005 (234,85 y 234,81): la diferencia es el redondeo de la media a cuatro decimales.

3. En el stream de transacciones de OANDA llega un `HEARTBEAT` con `lastTransactionID` 6413 y el cliente solo ha visto hasta la 6411. ¿Qué hace?

   - A. Nada: los latidos no llevan información
   - B. Cierra la cuenta
   - C. Espera a que la 6412 llegue sola
   - D. Pide `GET /v3/accounts/{accountID}/transactions/sinceid?id=6411` para recuperar las que faltan antes de seguir

   *Respuesta D: Pide `GET /v3/accounts/{accountID}/transactions/sinceid?id=6411` para recuperar las que faltan antes de seguir.* El latido dice cuál es la última transacción de la cuenta. Si es mayor que la última vista, se ha perdido algo; el endpoint `sinceid` devuelve las transacciones posteriores a un identificador dado.

4. ¿Por qué un sistema en Linux no puede hacer `import MetaTrader5`?

   - A. Porque el paquete es de pago
   - B. Porque en PyPI solo hay ruedas `win_amd64`: el paquete es un conector con el terminal de MetaTrader 5, que es un programa de Windows
   - C. Porque necesita Python 2
   - D. Porque el bróker lo prohíbe

   *Respuesta B: Porque en PyPI solo hay ruedas `win_amd64`: el paquete es un conector con el terminal de MetaTrader 5, que es un programa de Windows.* La página de PyPI del paquete solo publica ruedas para Windows de 64 bits y su resumen lo define como «API Connector to MetaTrader 5 Terminal». El código Python habla con el terminal por comunicación entre procesos.

5. Con MetaTrader 5, un sistema consulta `symbol_info_tick` cada medio segundo. ¿Qué pierde?

   - A. Todos los ticks que llegan entre dos consultas, salvo el último; `copy_ticks_from` desde el último `time_msc` visto los recupera
   - B. Nada
   - C. Solo los del fin de semana
   - D. Las órdenes pendientes

   *Respuesta A: Todos los ticks que llegan entre dos consultas, salvo el último; `copy_ticks_from` desde el último `time_msc` visto los recupera.* `symbol_info_tick` devuelve el último tick. En la simulación de la lección el sondeo ve 103 de los 238 ticks de un minuto; pedir los ticks desde la última marca de tiempo vista los recoge todos.

## Ideas clave

- Alpaca y OANDA separan papel y real por URL y por credenciales; Tradier por URL y token, con datos retrasados 15 minutos en el sandbox. Una clave de papel no sirve en real, y conviene que el sistema lo compruebe antes de enviar.
- Cada API cuenta una ejecución a su manera: Alpaca da los números como texto y un `execution_id`; Tradier, como números y sin `last_fill_price` en el stream. Un normalizador las convierte a un formato interno antes de tocar posiciones.
- La identidad de la orden es el identificador propio (`client_order_id` en Alpaca, `tag` en Tradier, `clientExtensions` en OANDA, `magic` y `comment` en MT5): con él se consulta antes de reenviar.
- El stream de transacciones de OANDA es HTTP por trozos con latidos cada 5 segundos que llevan `lastTransactionID`; un hueco se rellena con `transactions/sinceid`.
- El paquete `MetaTrader5` solo existe para Windows y no tiene stream: se sondea, y para no perder ticks se piden desde el último `time_msc` visto.

## Recursos

### Research

- [Máquinas de Estado Finito para Ejecución de Trading Cuantitativo en Python](https://pythonparatrading.com/maquinas-estado-finito-trading-python/) · Research-0029 · 49 min

### Documentación

- [Alpaca: crear una orden (POST /v2/orders, esquemas CreateOrderRequest y Order)](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: colocar una orden y respuestas del stream de cuenta](https://docs.tradier.com/reference/brokerage-api-trading-place-order) · docs.tradier.com
- [OANDA v20: definiciones de transacciones y endpoints de transacciones](https://developer.oanda.com/rest-live-v20/transaction-df/) · developer.oanda.com
- [MetaTrader 5 para Python (MQL5)](https://www.mql5.com/en/docs/python_metatrader5) · mql5.com

### Datos

- [Datos del nivel 4: mensajes de ejemplo de brókeres (JSON)](https://pythonparatrading.com/curso/datos/nivel-4/brokers4_ejemplos.json) · Descarga · brokers4_ejemplos.json

Lección anterior: [4.4.2 · Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes](https://pythonparatrading.com/curso-python-trading/nivel-4/interactive-brokers-con-ib-async/)

Lección siguiente: [4.4.4 · APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase](https://pythonparatrading.com/curso-python-trading/nivel-4/apis-cripto-ccxt-y-nativas/)
