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.
- Módulo
- 4.4 · Brokers y APIs
- Duración
- 75 min
Lecciones del nivel 4
4.1 · Teoría y optimización de carteras
- 4.1.1 Markowitz: frontera eficiente y restricciones reales
- 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
- 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
- 4.1.4 Risk parity, mínima varianza, HRP y HERC
- 4.1.5 Optimización con CVaR, drawdown, rotación y costes
- 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
- 4.1.7 Rebalanceo y carteras de estrategias
- 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
4.2 · Gestión del riesgo y tamaño de posición
- 4.2.1 Riesgo por operación, stops y riesgo de ruina
- 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
- 4.2.3 VaR, Expected Shortfall y backtesting del VaR
- 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
- 4.2.5 Control del drawdown y kill switch
- 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
4.3 · Microestructura y ejecución
4.4 · Brokers y APIs
- 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
- 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
- 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
- 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
- 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
4.5 · Sistema de trading en vivo
- 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
- 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
- 4.5.3 Paper trading, shadow trading y seguimiento del slippage
- 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
- 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
- 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.
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]
| 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ó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:
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)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 entornoPor 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:
typepuede sermarket,limit,stop,stop_limitotrailing_stop(en acciones; en cripto, solomarket,limitystop_limit), ytime_in_force,day,gtc,opg(apertura),cls(cierre),iocofok.qtyynotional(importe en dólares, solo con órdenes a mercado yday) se excluyen entre sí. Las cantidades y los precios se mandan y se reciben como texto.client_order_ides el identificador propio, de hasta 128 caracteres; si no se manda, Alpaca genera uno. La orden se consulta después por él enGET /v2/orders:by_client_order_id?client_order_id=….order_classpermite órdenesbracket,ocoyotocontake_profitystop_loss.- Las respuestas de error documentadas para el
POSTson 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:
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 propioEl 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:
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})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.
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]])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.
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.
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:
breakauthorized
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:
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"])14 campos comunes; distintos: []
reconstruida: 434 234.8353
consultada: 434 234.8353 filledLos 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.
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']}")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 uniqueEl 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) aPOST /v1/accounts/{account_id}/orders, conclass(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,stopytag. - 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
POSTdevuelve{"order": {"id": …, "status": "ok", …}}. La página de errores avisa de que, ante un error de los sistemas que hay detrás, elPOSTpuede devolver un 200: hay que consultar la orden y mirar su estado y su propiedaderrors. - 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/sessiondevuelve unasessionidque hay que usar en menos de 5 minutos, y enwss://ws.tradier.com/v1/accounts/eventsse 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 servidorsandbox-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.
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).
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")})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ó.
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")))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: TrueLos 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.
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]
from decimal import Decimal
ESTADOS = {"alpaca": {"new": "aceptada", "accepted": "aceptada",
"partially_filled": "parcial",
"filled": "ejecutada", "canceled": "cancelada",
"expired": "cancelada", "rejected": "rechazada"},
"tradier": {"open": "aceptada", "pending": "aceptada",
"partially_filled": "parcial",
"filled": "ejecutada", "canceled": "cancelada",
"expired": "cancelada", "rejected": "rechazada"}}
@dataclass(frozen=True)
class Ejecucion:
broker: str
orden: str
cantidad: int
precio: Decimal
acumulada: int
estado: str
def desde_alpaca(d):
return Ejecucion("alpaca", d["order"]["client_order_id"],
int(d["qty"]), Decimal(d["price"]),
int(d["order"]["filled_qty"]),
ESTADOS["alpaca"][d["order"]["status"]])
def desde_tradier(e, precio):
return Ejecucion("tradier", str(e["id"]),
int(e["last_fill_quantity"]),
Decimal(str(precio)).quantize(Decimal("0.01")),
int(e["exec_quantity"]),
ESTADOS["tradier"][e["status"]])
print(desde_alpaca(eventos[-1]))
print(desde_tradier({"id": 228005, "status": "filled",
"last_fill_quantity": 18.0,
"exec_quantity": 434.0}, 234.8099))Ejecucion(broker='alpaca', orden='R00005', cantidad=18, precio=Decimal('234.81'), acumulada=434, estado='ejecutada')
Ejecucion(broker='tradier', orden='228005', cantidad=18, precio=Decimal('234.81'), acumulada=434, estado='ejecutada')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é 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á:
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] + "…")[]
['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:
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__)no documentados: {'requestedUnits'}
obsoletos usados: ['gainQuoteHomeConversionFactor', 'lossQuoteHomeConversionFactor', 'price']
precio oficial (tradeOpened): 1.16451 | fullVWAP: 1.16451 | tipo: strEl 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:
fill = {k: v for k, v in tx.items() if k != "requestedUnits"}
T0 = 1_760_448_900 # 2025-10-14 13:35:00 UTC
def hora(s):
import datetime as dt
t = dt.datetime.fromtimestamp(T0 + s, dt.UTC)
return t.strftime("%Y-%m-%dT%H:%M:%S.000000000Z")
TRANS = [
(0.9, {"id": "6409", "type": "MARKET_ORDER",
"instrument": "EUR_USD", "units": "10000"}),
(1.0, fill),
(7.2, {"id": "6411", "type": "STOP_LOSS_ORDER", "tradeID": "6410",
"price": "1.15900"}),
(11.6, {"id": "6412", "type": "ORDER_CANCEL", "orderID": "6411",
"reason": "CLIENT_REQUEST"}),
]
PERDIDA = "6411"
def linea_tiempo(fin=20):
sucesos = [(s, t) for s, t in TRANS]
sucesos += [(s, None) for s in range(5, fin + 1, 5)]
ultima = "6408"
for s, t in sorted(sucesos, key=lambda x: x[0]):
if t is None:
yield s, {"type": "HEARTBEAT", "lastTransactionID": ultima,
"time": hora(s)}
else:
ultima = t["id"]
yield s, t | {"time": hora(s)}
async def oanda_stream(req):
r = web.StreamResponse()
await r.prepare(req)
for s, msg in linea_tiempo():
if msg.get("id") != PERDIDA:
await r.write((json.dumps(msg) + "\n").encode())
return r
async def oanda_sinceid(req):
desde = int(req.query["id"])
return web.json_response({"transactions": [
t | {"time": hora(s)} for s, t in TRANS
if int(t["id"]) > desde],
"lastTransactionID": TRANS[-1][1]["id"]})
app_o = web.Application()
app_o.router.add_get("/v3/accounts/{a}/transactions/stream",
oanda_stream)
app_o.router.add_get("/v3/accounts/{a}/transactions/sinceid",
oanda_sinceid)
corredor_o = web.AppRunner(app_o)
await corredor_o.setup()
sitio_o = web.TCPSite(corredor_o, "127.0.0.1", 0)
await sitio_o.start()
BASE_O = ("http://127.0.0.1:"
f"{sitio_o._server.sockets[0].getsockname()[1]}"
"/v3/accounts/101-004-0418272-001")
print([(s, m.get("id", m["type"])) for s, m in linea_tiempo()])[(0.9, '6409'), (1.0, '6410'), (5, 'HEARTBEAT'), (7.2, '6411'), (10, 'HEARTBEAT'), (11.6, '6412'), (15, 'HEARTBEAT'), (20, 'HEARTBEAT')]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:
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)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.
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:
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_PENDINGcoloca una orden pendiente (ORDER_TYPE_BUY_LIMIT,SELL_LIMIT,BUY_STOP,SELL_STOP,BUY_STOP_LIMIT,SELL_STOP_LIMIT);TRADE_ACTION_SLTPcambia el stop y el objetivo de una posición abierta;TRADE_ACTION_MODIFYyTRADE_ACTION_REMOVEmodifican o retiran una pendiente.order_checkcomprueba fondos y margen y devuelve unMqlTradeCheckResult; la documentación avisa de que una comprobación correcta no garantiza que la operación se ejecute.magices 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_modedice 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:
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")True True
acción: TRADE_ACTION_DEAL | tipo compra: True | relleno IOC: True | hecho: True
precio 1.16452 = ask 1.16452; horquilla 8.0 puntosLos 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):
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)}")ticks del minuto: 238
sondeo de symbol_info_tick cada 500 ms: 103
copy_ticks_from desde el último time_msc: 238El 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.
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_modeen MetaTrader 5). - Tipos de los números. Texto en Alpaca y OANDA, números en Tradier,
floaten MetaTrader 5. Sin normalizar,"27" + "389"es"27389". - Campos que cambian.
order_typeobsoleto en Alpaca;pricey los factores de conversión obsoletos en OANDA;exec_quantityyexecuted_quantityen 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,
sinceiddesde la última transacción vista. - Errores con 200. Tradier puede devolver 200 al
POSTcon la orden rechazada después: la respuesta delPOSTno 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,
initializefalla y el sistema tiene que saberlo.
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")simuladores cerradosEjercicios
- 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 emitirnewy despuéscanceled, y la consulta final debe darstatuscanceledconfilled_qty"0". Usa un guion sin ejecuciones.
Solución
async def alpaca_cancelar(req):
o = nucleo.ordenes.get(req.match_info["oid"])
if o is None or o.estado in FINALES:
return error(422, 42210000, "order is not cancelable")
o.estado, o.ultima = "canceled", "2025-10-14T13:40:00.000000Z"
await nucleo.publicar("canceled", o, ts=o.ultima)
return web.Response(status=204)
nucleo.guiones["R9100"] = ("2025-10-14T13:36:00.000000Z",
"2025-10-14T13:36:00.030000Z", [])
app_c = web.Application()
app_c.router.add_post("/v2/orders", alpaca_post)
app_c.router.add_delete("/v2/orders/{oid}", alpaca_cancelar)
app_c.router.add_get("/stream", alpaca_stream)
corredor_c = web.AppRunner(app_c)
await corredor_c.setup()
sitio_c = web.TCPSite(corredor_c, "127.0.0.1", 0)
await sitio_c.start()
BASE_C = ("http://127.0.0.1:"
f"{sitio_c._server.sockets[0].getsockname()[1]}")
async with ClientSession() as s:
w = await s.ws_connect(BASE_C + "/stream")
await w.send_json({"action": "auth", "key": "PK-FALSA-PAPEL",
"secret": "FALSA-SECRETO-PAPEL"})
await w.receive_json()
await w.send_json({"action": "listen",
"data": {"streams": ["trade_updates"]}})
await w.receive_json()
c = cuerpo | {"client_order_id": "R9100", "qty": "100",
"type": "limit", "limit_price": "230.00"}
async with s.post(BASE_C + "/v2/orders", json=c, headers=CAB) as r:
oid = (await r.json())["id"]
print("new:", (await w.receive_json())["data"]["event"])
async with s.delete(f"{BASE_C}/v2/orders/{oid}",
headers=CAB) as r:
print("DELETE:", r.status)
d = (await w.receive_json())["data"]
print(d["event"], d["order"]["status"], d["order"]["filled_qty"])
await nucleo.cerrar()
await w.close()
await corredor_c.cleanup()new: new
DELETE: 204
canceled canceled 0El 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.
- 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
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)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.
- 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
-
Alpaca, Create an Order (esquemas
CreateOrderRequestyOrder), Get Order by Client Order ID, Authentication, Paper Trading y WebSocket Streaming;alpaca-py0.44.0 en PyPI. Consultadas el 22/10/2026. ↩ ↩2 ↩3 -
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
-
OANDA, Order Definitions, Transaction Definitions, Transaction Endpoints (stream y
sinceid) y Development Guide (URLs y límites). Consultadas el 22/10/2026. ↩ ↩2 ↩3 -
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
Una orden de Alpaca enviada con
qty434 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
Decimalofloat) antes de operar con él, igual queprice,qtyyfilled_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
Decimalofloat) antes de operar con él, igual queprice,qtyyfilled_avg_priceEl esquema
Orderde Alpaca declara las cantidades y los precios comostring. 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.- AEs texto: hay que convertirlo (a
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_priceyexec_quantityantes 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_priceyexec_quantityantes y después: (media nueva × cantidad nueva − media anterior × cantidad anterior) /last_fill_quantityEl 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.
En el stream de transacciones de OANDA llega un
HEARTBEATconlastTransactionID6413 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=6411para recuperar las que faltan antes de seguir
Ver respuesta
Respuesta D. Pide
GET /v3/accounts/{accountID}/transactions/sinceid?id=6411para recuperar las que faltan antes de seguirEl 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
sinceiddevuelve las transacciones posteriores a un identificador dado.¿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 WindowsLa 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.
Con MetaTrader 5, un sistema consulta
symbol_info_tickcada medio segundo. ¿Qué pierde?- ATodos los ticks que llegan entre dos consultas, salvo el último;
copy_ticks_fromdesde el últimotime_mscvisto 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_fromdesde el últimotime_mscvisto los recuperasymbol_info_tickdevuelve 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.- ATodos los ticks que llegan entre dos consultas, salvo el último;
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 sinlast_fill_priceen 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_iden Alpaca,tagen Tradier,clientExtensionsen OANDA,magicycommenten 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 contransactions/sinceid. - El paquete
MetaTrader5solo existe para Windows y no tiene stream: se sondea, y para no perder ticks se piden desde el últimotime_mscvisto.