Lección 4.4.3 · 75 min

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.

Fig. — La orden R00005 en el stream trade_updates de Alpaca: títulos ejecutados desde el envío
Lecciones del nivel 4
  1. 4.1 · Teoría y optimización de carteras

    1. 4.1.1 Markowitz: frontera eficiente y restricciones reales
    2. 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
    3. 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
    4. 4.1.4 Risk parity, mínima varianza, HRP y HERC
    5. 4.1.5 Optimización con CVaR, drawdown, rotación y costes
    6. 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
    7. 4.1.7 Rebalanceo y carteras de estrategias
    8. 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
  2. 4.2 · Gestión del riesgo y tamaño de posición

    1. 4.2.1 Riesgo por operación, stops y riesgo de ruina
    2. 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
    3. 4.2.3 VaR, Expected Shortfall y backtesting del VaR
    4. 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
    5. 4.2.5 Control del drawdown y kill switch
    6. 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
  3. 4.3 · Microestructura y ejecución

    1. 4.3.1 Implementation shortfall, VWAP, TWAP y Almgren-Chriss
    2. 4.3.2 Order flow imbalance, señales de microestructura y latencia
  4. 4.4 · Brokers y APIs

    1. 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
    2. 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
    4. 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
    5. 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
  5. 4.5 · Sistema de trading en vivo

    1. 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
    2. 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
    3. 4.5.3 Paper trading, shadow trading y seguimiento del slippage
    4. 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
    5. 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
    6. 4.5.6 NautilusTrader o LEAN en real: del backtest a producción con el mismo código

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.

SistemaAlpaca REST y WSTradier REST y WSOANDA REST y HTTPPaquete MT5Terminal Windows
Fuente 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]
AlpacaTradierOANDA v20MetaTrader 5
TransporteREST JSON; websocket para trade_updatesREST con formulario; respuesta JSON si se pide (por defecto XML); websocket de cuenta (beta)REST JSON; streams por HTTP por trozosLlamadas a un terminal local por comunicación entre procesos
AutenticaciónCabeceras APCA-API-KEY-ID y APCA-API-SECRET-KEYAuthorization: BearerAuthorization: BearerUsuario, contraseña y servidor en initialize
Papel y realpaper-api.alpaca.markets y api.alpaca.markets, con claves distintassandbox.tradier.com y api.tradier.com; el sandbox tiene datos retrasados 15 minutosapi-fxpractice.oanda.com y api-fxtrade.oanda.com; streams en stream-…Cuenta demo o real del bróker, en el mismo terminal
NúmerosTexto ("434", "234.8353")Números JSONTextofloat de Python
Identificador propioclient_order_id (hasta 128 caracteres)tagclientExtensions.idmagic y comment

Las fuentes de cada fila están en las secciones de cada bróker1Nota 1 · Alpaca, Create an Order (esquemas CreateOrderRequest y Order), Get Order by Client Order ID, Authentication, Paper Trading y WebSocket Streaming; alpaca-py 0.44.0 en PyPI. Consultadas el 22/10/2026.2Nota 2 · Tradier, Endpoints, Place Order, Get an order, Error responses, Response format, Account streaming responses, Account WebSocket streaming, FAQ y Rate limiting. Consultadas el 22/10/2026.3Nota 3 · OANDA, Order Definitions, Transaction Definitions, Transaction Endpoints (stream y sinceid) y Development Guide (URLs y límites). Consultadas el 22/10/2026.4Nota 4 · MetaTrader5 en PyPI (versión 5.0.6231, solo ruedas win_amd64); MQL5, MetaTrader para Python, initialize, order_send, order_check, copy_ticks_from, tipos de operación y códigos de retorno. Consultadas el 22/10/2026.. 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:

IN [01]python
import os

URLS = {
    "alpaca": {"papel": "https://paper-api.alpaca.markets",
               "real": "https://api.alpaca.markets"},
    "tradier": {"papel": "https://sandbox.tradier.com/v1",
                "real": "https://api.tradier.com/v1"},
    "oanda": {"papel": "https://api-fxpractice.oanda.com",
              "real": "https://api-fxtrade.oanda.com"},
}

class ModoNoPermitido(RuntimeError):
    pass

def url_base(broker, modo):
    if modo == "real" and os.environ.get("PPT_REAL") != "si":
        raise ModoNoPermitido(
            f"{broker}: 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)
OUT [01]URL de cada bróker según el modo, con el real bloqueado
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 JSON1. 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:

IN [02]alpaca_orden.pypython
import os
from alpaca.trading.client import TradingClient
from alpaca.trading.enums import OrderSide, TimeInForce
from alpaca.trading.requests import MarketOrderRequest
from alpaca.trading.stream import TradingStream

clave = os.environ["APCA_API_KEY_ID"]
secreto = os.environ["APCA_API_SECRET_KEY"]
cliente = TradingClient(clave, secreto, paper=True)
orden = cliente.submit_order(MarketOrderRequest(
    symbol="SPY", qty=434, side=OrderSide.BUY,
    time_in_force=TimeInForce.DAY, client_order_id="R00005"))
print(orden.status, orden.client_order_id)

async def al_recibir(evento):               # 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:

IN [03]python
import json
from pathlib import Path

EJ = json.loads(Path("brokers4_ejemplos.json").read_text())
ORDER_DOC = set("""id client_order_id created_at updated_at
submitted_at filled_at expired_at expires_at canceled_at failed_at
replaced_at replaced_by replaces asset_id symbol asset_class notional
qty filled_qty filled_avg_price order_class order_type type side
position_intent time_in_force limit_price stop_price status
extended_hours legs trail_percent trail_price hwm ratio_qty group_id
group_role""".split())
final = EJ["alpaca"]["orden_final"]
print("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})
OUT [03]Los campos del ejemplo frente al esquema Order de Alpaca
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.

IN [04]python
import asyncio
from dataclasses import dataclass, field, replace

@dataclass
class Orden:
    id: str
    cid: str
    simbolo: str
    lado: str
    cantidad: int
    tipo: str
    tif: str
    creada: str
    enviada: str
    cuenta: str
    estado: str = "new"
    ejecutada: int = 0
    efectivo: float = 0.0
    ultima: str = ""
    ejecuciones: list = field(default_factory=list)

    @property
    def media(self):
        if not self.ejecutada:
            return None
        return self.efectivo / self.ejecutada

class Nucleo:
    """Ó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]])
OUT [04]El núcleo del bróker simulado
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.

IN [05]python
from aiohttp import web, WSMsgType

CLAVES_ALPACA = {"PK-FALSA-PAPEL": "FALSA-SECRETO-PAPEL"}
TIPOS = {"market", "limit", "stop", "stop_limit", "trailing_stop"}
TIFS = {"day", "gtc", "opg", "cls", "ioc", "fok"}
PRECIO_REF = {"ORSC": 234.80}             # 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.

IN [06]python
from aiohttp import ClientSession

app = web.Application()
app.router.add_post("/v2/orders", alpaca_post)
app.router.add_get("/v2/orders:by_client_order_id", alpaca_por_cid)
app.router.add_get("/stream", alpaca_stream)
corredor = web.AppRunner(app)
await corredor.setup()
sitio = web.TCPSite(corredor, "127.0.0.1", 0)
await sitio.start()
BASE = f"http://127.0.0.1:{sitio._server.sockets[0].getsockname()[1]}"
CAB = {"APCA-API-KEY-ID": "PK-FALSA-PAPEL",
       "APCA-API-SECRET-KEY": "FALSA-SECRETO-PAPEL"}
FINALES = {"filled", "canceled", "expired", "rejected"}

sesion = ClientSession()
ws = await sesion.ws_connect(BASE + "/stream")
await ws.send_json({"action": "auth", "key": CAB["APCA-API-KEY-ID"],
                    "secret": CAB["APCA-API-SECRET-KEY"]})
print((await ws.receive_json())["data"]["status"])
await ws.send_json({"action": "listen",
                    "data": {"streams": ["trade_updates"]}})
print((await ws.receive_json())["stream"])

cuerpo = {"symbol": "ORSC", "qty": "434", "side": "buy",
          "type": "market", "time_in_force": "day",
          "client_order_id": "R00005"}
async with sesion.post(BASE + "/v2/orders", json=cuerpo,
                       headers=CAB) as r:
    print("POST:", r.status, (await r.json())["status"])

acum, efectivo, eventos = 0, 0.0, []
while True:
    d = (await ws.receive_json())["data"]
    if "qty" in d:
        acum += int(d["qty"])
        efectivo += int(d["qty"]) * float(d["price"])
    eventos.append(d)
    print(f"{d['timestamp'][11:23]} {d['event']:<13}"
          f" 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
OUT [06]Enviar R00005 y reconstruirla desde trade_updates
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:

IN [07]python
async with sesion.get(BASE + "/v2/orders:by_client_order_id",
                      params={"client_order_id": "R00005"},
                      headers=CAB) as r:
    consultada = await r.json()
comunes = sorted(set(consultada) & set(final))
distintos = [k for k in comunes if consultada[k] != final[k]]
print(len(comunes), "campos comunes; distintos:", distintos)
print("reconstruida:", acum, f"{efectivo / acum:.4f}")
print("consultada:  ", consultada["filled_qty"],
      consultada["filled_avg_price"], consultada["status"])
OUT [07]Consulta por client_order_id y comparación con el ejemplo
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.

IN [08]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']}")
OUT [08]422, 403 y un identificador repetido
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 trabajar2:

  • 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.
IN [09]tradier_orden.pypython
import json
import os
from urllib.parse import urlencode
from urllib.request import Request, urlopen

BASE = "https://sandbox.tradier.com/v1"        # 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).

IN [10]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")})
OUT [10]La fachada de Tradier: otro servidor sobre el mismo núcleo
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ó.

IN [11]python
CAB_T = {"Authorization": f"Bearer {TOKEN_TRADIER}",
         "Accept": "application/json"}
async with sesion.post(BASE_T + "/accounts/events/session",
                       headers=CAB_T) as r:
    sid = (await r.json())["stream"]["sessionid"]
ws_t = await sesion.ws_connect(BASE_T + "/accounts/events")
await ws_t.send_json({"events": ["order"], "sessionid": sid,
                      "excludeAccounts": []})
print("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")))
OUT [11]R00006 por Tradier: el precio de cada ejecución, deducido
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.

trade_updatesNormalizadorEventos TradierTransacciones OANDAEjecución internaPosicionesConciliación
Fuente 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]
IN [12]python
from decimal import Decimal

ESTADOS = {"alpaca": {"new": "aceptada", "accepted": "aceptada",
                      "partially_filled": "parcial",
                      "filled": "ejecutada", "canceled": "cancelada",
                      "expired": "cancelada", "rejected": "rechazada"},
           "tradier": {"open": "aceptada", "pending": "aceptada",
                       "partially_filled": "parcial",
                       "filled": "ejecutada", "canceled": "cancelada",
                       "expired": "cancelada", "rejected": "rechazada"}}

@dataclass(frozen=True)
class Ejecucion:
    broker: str
    orden: str
    cantidad: int
    precio: Decimal
    acumulada: int
    estado: str

def desde_alpaca(d):
    return Ejecucion("alpaca", d["order"]["client_order_id"],
                     int(d["qty"]), Decimal(d["price"]),
                     int(d["order"]["filled_qty"]),
                     ESTADOS["alpaca"][d["order"]["status"]])

def desde_tradier(e, precio):
    return Ejecucion("tradier", str(e["id"]),
                     int(e["last_fill_quantity"]),
                     Decimal(str(precio)).quantize(Decimal("0.01")),
                     int(e["exec_quantity"]),
                     ESTADOS["tradier"][e["status"]])

print(desde_alpaca(eventos[-1]))
print(desde_tradier({"id": 228005, "status": "filled",
                     "last_fill_quantity": 18.0,
                     "exec_quantity": 434.0}, 234.8099))
OUT [12]Ejecuciones de Alpaca y de Tradier en un formato interno
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 cuenta3:

Tipo (type)Qué hacetimeInForce admitidos
MARKETEjecuta al precio de mercado, con priceBound opcionalFOK (por defecto) o IOC
LIMITCompra a ese precio o mejorGTC, GTD, GFD, FOK, IOC
STOPSe activa al tocar el precio y ejecuta como mercadoLos mismos
MARKET_IF_TOUCHEDComo LIMIT pero activada al tocar, sin importar desde dóndeLos 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á:

IN [13]python
TIF_OANDA = {"MARKET": {"FOK", "IOC"},
             "LIMIT": {"GTC", "GTD", "GFD", "FOK", "IOC"},
             "STOP": {"GTC", "GTD", "GFD", "FOK", "IOC"},
             "MARKET_IF_TOUCHED": {"GTC", "GTD", "GFD", "FOK", "IOC"}}

def validar_oanda(o):
    errores = []
    if o["timeInForce"] not in TIF_OANDA[o["type"]]:
        errores.append(f"{o['type']} 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] + "…")
OUT [13]Una orden límite GTD con stop y take profit al ejecutarse
[]
['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:

IN [14]python
FILL_DOC = set("""id time userID accountID batchID requestID type
orderID clientOrderID instrument units gainQuoteHomeConversionFactor
lossQuoteHomeConversionFactor homeConversionFactors price fullVWAP
fullPrice reason pl quotePL financing baseFinancing quoteFinancing
commission guaranteedExecutionFee quoteGuaranteedExecutionFee
accountBalance tradeOpened tradesClosed tradeReduced
halfSpreadCost""".split())
OBSOLETOS = {"price", "gainQuoteHomeConversionFactor",
             "lossQuoteHomeConversionFactor"}
tx = EJ["oanda_v20"]["transaction"]
print("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__)
OUT [14]ORDER_FILL del ejemplo frente a OrderFillTransaction
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 cuenta3. 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:

IN [15]python
fill = {k: v for k, v in tx.items() if k != "requestedUnits"}
T0 = 1_760_448_900                     # 2025-10-14 13:35:00 UTC

def hora(s):
    import datetime as dt
    t = dt.datetime.fromtimestamp(T0 + s, dt.UTC)
    return t.strftime("%Y-%m-%dT%H:%M:%S.000000000Z")

TRANS = [
    (0.9, {"id": "6409", "type": "MARKET_ORDER",
           "instrument": "EUR_USD", "units": "10000"}),
    (1.0, fill),
    (7.2, {"id": "6411", "type": "STOP_LOSS_ORDER", "tradeID": "6410",
           "price": "1.15900"}),
    (11.6, {"id": "6412", "type": "ORDER_CANCEL", "orderID": "6411",
            "reason": "CLIENT_REQUEST"}),
]
PERDIDA = "6411"

def linea_tiempo(fin=20):
    sucesos = [(s, t) for s, t in TRANS]
    sucesos += [(s, None) for s in range(5, fin + 1, 5)]
    ultima = "6408"
    for s, t in sorted(sucesos, key=lambda x: x[0]):
        if t is None:
            yield s, {"type": "HEARTBEAT", "lastTransactionID": ultima,
                      "time": hora(s)}
        else:
            ultima = t["id"]
            yield s, t | {"time": hora(s)}

async def oanda_stream(req):
    r = web.StreamResponse()
    await r.prepare(req)
    for s, msg in linea_tiempo():
        if msg.get("id") != PERDIDA:
            await r.write((json.dumps(msg) + "\n").encode())
    return r

async def oanda_sinceid(req):
    desde = int(req.query["id"])
    return web.json_response({"transactions": [
        t | {"time": hora(s)} for s, t in TRANS
        if int(t["id"]) > desde],
        "lastTransactionID": TRANS[-1][1]["id"]})

app_o = web.Application()
app_o.router.add_get("/v3/accounts/{a}/transactions/stream",
                     oanda_stream)
app_o.router.add_get("/v3/accounts/{a}/transactions/sinceid",
                     oanda_sinceid)
corredor_o = web.AppRunner(app_o)
await corredor_o.setup()
sitio_o = web.TCPSite(corredor_o, "127.0.0.1", 0)
await sitio_o.start()
BASE_O = ("http://127.0.0.1:"
          f"{sitio_o._server.sockets[0].getsockname()[1]}"
          "/v3/accounts/101-004-0418272-001")
print([(s, m.get("id", m["type"])) for s, m in linea_tiempo()])
OUT [15]Servidor de transacciones de OANDA con una línea perdida
[(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:

IN [16]python
vistas, ultima = [], 6408
async with sesion.get(BASE_O + "/transactions/stream") as r:
    async for crudo in r.content:
        m = json.loads(crudo)
        if m["type"] != "HEARTBEAT":
            if m["id"] in vistas:
                print("  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)
OUT [16]Cliente del stream: el latido delata el hueco
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

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

IN [17]mt5_pendientes.pypython
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:

IN [18]python
RESULT_DOC = {"retcode", "deal", "order", "volume", "price", "bid",
              "ask", "comment", "request_id", "retcode_external",
              "request"}
REQUEST_DOC = {"action", "magic", "order", "symbol", "volume", "price",
               "stoplimit", "sl", "tp", "deviation", "type",
               "type_filling", "type_time", "expiration", "comment",
               "position", "position_by"}
CONST = {"TRADE_ACTION_DEAL": 1, "TRADE_ACTION_PENDING": 5,
         "TRADE_ACTION_SLTP": 6, "ORDER_TYPE_BUY": 0,
         "ORDER_TYPE_BUY_LIMIT": 2, "ORDER_FILLING_IOC": 1,
         "ORDER_FILLING_RETURN": 2, "TRADE_RETCODE_DONE": 10009}
res = EJ["metatrader5"]["resultado"]
print(set(res) == RESULT_DOC, set(res["request"]) == REQUEST_DOC)
nombre = {v: k for k, v in CONST.items() if k.startswith("TRADE_A")}
print("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")
OUT [18]El OrderSendResult del ejemplo y las constantes del paquete
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):

IN [19]python
import numpy as np

rng = np.random.default_rng(4403)
llegadas = np.cumsum(rng.exponential(250, 400))      # ms
ticks = [(int(t), round(1.16450 + 1e-5 * k, 5))
         for k, t in enumerate(llegadas[llegadas < 60_000])]

def symbol_info_tick(ahora):
    previos = [t for t in ticks if t[0] <= ahora]
    return previos[-1] if previos else None

def copy_ticks_from(desde, cuantos=1000, ahora=None):
    return [t for t in ticks if desde <= t[0] <= ahora][:cuantos]

vistos_sondeo = {symbol_info_tick(ms) for ms in range(500, 60_001, 500)}
vistos_sondeo.discard(None)
vistos_copia, ultimo = [], -1
for ms in range(500, 60_001, 500):
    nuevos = [t for t in copy_ticks_from(ultimo, ahora=ms)
              if t[0] > ultimo]
    vistos_copia += nuevos
    if nuevos:
        ultimo = nuevos[-1][0]
print(f"ticks 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)}")
OUT [19]Sondeo del último tick frente a pedir los ticks desde el último visto
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

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.
IN [20]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")
OUT [20]Cerrar los servidores del simulador
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
IN [21]python
async def alpaca_cancelar(req):
    o = nucleo.ordenes.get(req.match_info["oid"])
    if o is None or o.estado in FINALES:
        return error(422, 42210000, "order is not cancelable")
    o.estado, o.ultima = "canceled", "2025-10-14T13:40:00.000000Z"
    await nucleo.publicar("canceled", o, ts=o.ultima)
    return web.Response(status=204)

nucleo.guiones["R9100"] = ("2025-10-14T13:36:00.000000Z",
                           "2025-10-14T13:36:00.030000Z", [])
app_c = web.Application()
app_c.router.add_post("/v2/orders", alpaca_post)
app_c.router.add_delete("/v2/orders/{oid}", alpaca_cancelar)
app_c.router.add_get("/stream", alpaca_stream)
corredor_c = web.AppRunner(app_c)
await corredor_c.setup()
sitio_c = web.TCPSite(corredor_c, "127.0.0.1", 0)
await sitio_c.start()
BASE_C = ("http://127.0.0.1:"
          f"{sitio_c._server.sockets[0].getsockname()[1]}")

async with ClientSession() as s:
    w = await s.ws_connect(BASE_C + "/stream")
    await w.send_json({"action": "auth", "key": "PK-FALSA-PAPEL",
                       "secret": "FALSA-SECRETO-PAPEL"})
    await w.receive_json()
    await w.send_json({"action": "listen",
                       "data": {"streams": ["trade_updates"]}})
    await w.receive_json()
    c = cuerpo | {"client_order_id": "R9100", "qty": "100",
                  "type": "limit", "limit_price": "230.00"}
    async with s.post(BASE_C + "/v2/orders", json=c, headers=CAB) as r:
        oid = (await r.json())["id"]
    print("new:", (await w.receive_json())["data"]["event"])
    async with s.delete(f"{BASE_C}/v2/orders/{oid}",
                        headers=CAB) as r:
        print("DELETE:", r.status)
    d = (await w.receive_json())["data"]
    print(d["event"], d["order"]["status"], d["order"]["filled_qty"])
    await nucleo.cerrar()
    await w.close()
await corredor_c.cleanup()
OUT [21]Una orden límite cancelada
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.

  1. 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
IN [22]python
previo, errores = 0.0, []
acum_q, acum_c = 0, 0.0
for ts, q, px, eid in guion_r5[2]:
    acum_q += q
    acum_c += q * px
    media = round(acum_c / acum_q, 4)       # 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)
OUT [22]Error de la deducción del precio por redondeo de la media
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.

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

Footnotes

  1. Alpaca, Create an Order (esquemas CreateOrderRequest y Order), Get Order by Client Order ID, Authentication, Paper Trading y WebSocket Streaming; alpaca-py 0.44.0 en PyPI. Consultadas el 22/10/2026. ↩ ↩2 ↩3

  2. Tradier, Endpoints, Place Order, Get an order, Error responses, Response format, Account streaming responses, Account WebSocket streaming, FAQ y Rate limiting. Consultadas el 22/10/2026. ↩ ↩2

  3. OANDA, Order Definitions, Transaction Definitions, Transaction Endpoints (stream y sinceid) y Development Guide (URLs y límites). Consultadas el 22/10/2026. ↩ ↩2 ↩3

  4. MetaTrader5 en PyPI (versión 5.0.6231, solo ruedas win_amd64); MQL5, MetaTrader para Python, initialize, order_send, order_check, copy_ticks_from, tipos de operación y códigos de retorno. Consultadas el 22/10/2026. ↩ ↩2

Respuesta plegada en cada pregunta

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?

    • AEs texto: hay que convertirlo (a Decimal o float) antes de operar con él, igual que price, qty y filled_avg_price
    • BEs un número entero: se usa tal cual
    • CEs un error del simulador
    • DDepende del día
    Ver respuesta

    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?

    • ANo se puede
    • BEs siempre igual a avg_fill_price
    • CCon avg_fill_price y exec_quantity antes y después: (media nueva × cantidad nueva − media anterior × cantidad anterior) / last_fill_quantity
    • DPidiendo el libro de órdenes
    Ver respuesta

    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?

    • ANada: los latidos no llevan información
    • BCierra la cuenta
    • CEspera a que la 6412 llegue sola
    • DPide GET /v3/accounts/{accountID}/transactions/sinceid?id=6411 para recuperar las que faltan antes de seguir
    Ver respuesta

    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?

    • APorque el paquete es de pago
    • BPorque en PyPI solo hay ruedas win_amd64: el paquete es un conector con el terminal de MetaTrader 5, que es un programa de Windows
    • CPorque necesita Python 2
    • DPorque el bróker lo prohíbe
    Ver respuesta

    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?

    • ATodos los ticks que llegan entre dos consultas, salvo el último; copy_ticks_from desde el último time_msc visto los recupera
    • BNada
    • CSolo los del fin de semana
    • DLas órdenes pendientes
    Ver respuesta

    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

Datos