ibkr-httpapi: Seis Clases de Activos, Cero VM de Windows, Dinero de Verdad

Ya me quemé un mes de vida construyendo mt5-httpapi, una VM de Windows entera arrancada bajo QEMU/KVM dentro de Docker, solo para poder hablar con un bróker que únicamente publica una wheel de Python para Windows. Funciona. Y también está completamente ido: una instalación de Windows 11 virtualizada por hardware, limpiada hasta los huesos, ejecutando MetaTrader 5 en modo portable, para poder pegarle a un endpoint REST en vez de escribir MQL5. Lo volvería a hacer. Pero cuando me puse a enganchar Interactive Brokers, estuve diez minutos largos en tensión esperando la misma pesadilla: algún binario de IBKR con sabor a Windows, otra VM, otra ISO de 4GB que descargar, otra ronda de «por qué se me queda pillado el cursor en la ventana de noVNC».
No pasó. IB Gateway, el terminal de trading sin interfaz que IBKR publica para el acceso por API, corre nativo en Linux. Sin .exe. Sin wine. Sin una wheel que solo compila en win_amd64. Solo un proceso JVM que puedes soltar directamente en un container. Así que ibkr-httpapi es el proyecto hermano que mt5-httpapi siempre mereció: la misma forma, curl a la entrada, JSON a la salida, un bearer token, un esquema de URL por clase de activo, salvo que todo el stack son containers de Linux. Sin KVM. Sin ISO de Windows. Sin noVNC a un escritorio entero solo para clicar por un asistente de instalación de Windows una vez por década.

Hablar con un Bróker Es una Miseria

La historia de la API de Interactive Brokers es de esas cosas que te hacen entender por qué todos los bots de trading retail de GitHub están o abandonados o tres años desfasados:

  • La API de TWS solo existe si hay algo con interfaz gráfica corriendo, o TWS mismo, o su hermano sin interfaz IB Gateway. No hay un «llama a un endpoint REST y ya», hay un protocolo sobre socket que solo habla con una sesión de escritorio viva.
  • Para loguear esa sesión hay que clicar por una pantalla de login, aceptar condiciones, a veces darle a un push de 2FA en el móvil, todos los días, porque IBKR desconecta la gateway a la fuerza cada 24 horas, te guste o no.
  • ib_async (el fork mantenido del abandonado ib_insync) te da un wrapper de asyncio decente sobre ese socket, pero te entrega objetos Contract en crudo, sigues construyendo a mano Stock frente a Option frente a Future frente a Bag para cada clase de activo, con campos obligatorios distintos en cada una.
  • IBKR te corta el acceso a la API por violaciones repetidas de pacing. No tienen ningún sistema de aviso suave, le pegas demasiado fuerte al endpoint de datos históricos y te comes una suspensión. La mayoría de scripts caseros tienen cero conciencia de rate limit hasta que les muerden una vez.
  • Cada llamada de datos de mercado que haces o se tira en cuanto la lees, o te escribes otra capa de persistencia en CSV/SQLite desde cero, otra vez, para el cuarto proyecto del año.
  • ¿Quieres RSI o MACD encima de las velas que acabas de sacar? Eso es otra dependencia, otra librería de indicadores, otro conjunto de casos límite alrededor de los periodos de calentamiento con NaN.

Nada de eso es culpa de IBKR exactamente, es una API de correduría de nivel profesional, no un juguete, pero significa que cada proyecto que quiere «dame el OHLC de AAPL por HTTP» acaba reinventando mal las mismas seis cosas. Me cansé de reinventarlas, así que construí el chisme una vez, bien hecho, y le puse una spec delante.

El Stack

El stack es un servicio FastAPI (ibkrapi/) puesto delante de un container de IB Gateway, hablándole por el socket de la API de TWS vía ib_async. Nada exótico, una única instancia IB compartida, guardada por un asyncio.Lock para que las peticiones concurrentes no se atropellen en la llamada de conexión, con backoff exponencial (arranca en el reconnect_backoff configurado, se dobla hasta reconnect_max_backoff) si el socket se cae alguna vez. Cada router tira de la conexión por una sola función, get_ib(), el primero que llama espera el handshake, todos los demás la reaprovechan. Cuando IBKR desconecta la gateway a la fuerza para su reinicio diario, la siguiente petición que entra reconecta bajo demanda en vez de tumbar la API entera.
El container de la gateway está construido a partir de gnzsnz/ib-gateway-docker, con IBC (IB Controller) horneado dentro para llevar el flujo de login sin interfaz, usuario y contraseña entran por un fichero .env.ibkr en gitignore, e IBC pilota la interfaz de login en nombre de IB Gateway para que nadie tenga que mover el ratón. Un detalle legal que conviene saber: la licencia del instalador de IBKR prohíbe redistribuir imágenes pre-construidas que contengan su binario, así que la imagen de la gateway la construyes en local en vez de bajarte el tag de Docker Hub de cualquier desconocido con el instalador de IBKR horneado dentro. docker-compose.yml.example lo deja escrito y trae por defecto un tag mutable :stable que se espera que fijes a un digest en cuanto hayas construido la tuya.

Seis Clases de Activos, Un Solo Esquema de URL

Cada tipo de mercado tiene su propio prefijo, en lugar de un endpoint «symbol» sobrecargado que adivina en silencio lo que querías decir:

/stocks/<symbol>              Equities (STK)
/options/<symbol>             Options (OPT) — ?expiry=YYYYMMDD&strike=N&right=C|P
/options/<symbol>/chain       Full option chain — all strikes × expirations
/futures/<symbol>             Futures (FUT) — ?expiry=YYYYMM&exchange=CME
/futures/<symbol>/continuous Continuous future — no expiry needed
/cfd/<symbol>                 CFDs
/forex/<pair>                 Currencies (CASH) — IDEALPRO default
/crypto/<symbol>              Crypto (CRYPTO) — PAXOS default

Acciones, opciones, futuros, CFD, forex, cripto, seis clases de activos, una fábrica de Contract por cada una en ibkrapi/contracts.py, cada una pidiendo justo los parámetros de desambiguación que esa clase necesita de verdad (las opciones quieren expiry/strike/right, los futuros quieren expiry/exchange, las acciones no quieren prácticamente nada) y rellenando el resto desde config.yaml:contract_defaults.<class> para que no repitas «SMART/USD» en cada llamada. Donde tiene sentido para esa clase de activo tienes los mismos cuatro verbos: detalles del contrato, un snapshot de tick en vivo (con las griegas enganchadas para opciones), velas históricas y ticks históricos en crudo. Encima de las seis clases de datos de mercado está la entrada de órdenes cross-asset, una lista de posiciones, el resumen de cuenta y un historial de ejecuciones y órdenes completadas, todo servido bajo el mismo prefijo /v1.

curl -H "Authorization: Bearer $API_TOKEN" 
    "http://localhost:8889/v1/stocks/AAPL/rates?duration=30+D&barSize=1d"
curl -H "Authorization: Bearer $API_TOKEN" 
    "http://localhost:8889/v1/options/AAPL/tick?expiry=20260619&strike=200&right=C"
curl -H "Authorization: Bearer $API_TOKEN" 
    "http://localhost:8889/v1/futures/ES/continuous?exchange=CME"

Primero la Spec, No a Ojo

Ninguno de los routers de arriba es un decorador de FastAPI escrito a mano y desperdigado por ficheros esperando que sigan sincronizados. api/v1.yaml es la fuente de verdad de verdad, un documento OpenAPI 3.1.0 con 42 operaciones y 34 esquemas, y make generate es el target paraguas que mueve tres generadores distintos a partir de él: fastapi-codegen (con plantillas Jinja propias) escupe ibkrapi/api/_generated/{models.py, routers/*.py} como handlers stub async que delegan directo en ibkrapi/api/impl.py, escrito a mano; oapi-codegen emite un cliente Go completamente tipado en pkg/clients/go/client.gen.go; y openapi-python-client genera un paquete cliente de Python independiente e instalable. Nueve routers se montan en la app en server.py, system, stocks, options, futures, cfd, forex, crypto, orders, history, todos bajo un prefijo fijo /v1. Nadie edita a mano nada bajo _generated/ ni pkg/clients/*; cambias la spec, vuelves a lanzar el generador, y la comprobación de deriva en CI tumba el build si se te olvida.

MCP, Para No Tener Que Darle a un Agente un Manual de curl

La misma app de FastAPI monta también un servidor MCP en /mcp, streamable-HTTP, en la raíz de la app, no bajo el prefijo /v1. Empezó su vida con tres herramientas genéricas (ping, endpoints, request) que dejaban a un agente llamar a cualquier ruta REST por método y path. Técnicamente funciona, pero significa que el modelo tiene que bajarse un catálogo de OpenAPI en tiempo de ejecución y luego adivinar su camino por una plantilla de path, que es exactamente el peaje de «léete la documentación primero» que la spec venía a matar. Así que eso se tiró: /mcp expone ahora 24 herramientas tipadas dedicadas, cada una con sus parámetros tipados y una descripción que el agente lee. El esquema es la documentación.
La parte interesante es lo que pasó con las seis clases de activos. Por REST son seis prefijos de URL. Por MCP se pliegan detrás de un único enum asset_class, stock, option, future, cfd, forex, crypto, de modo que get_contract, get_quote, get_rates y get_rates_ta hacen el papel de lo que si no serían 24 herramientas por clase casi idénticas, inflando el contexto del agente para cero significado añadido. Alrededor están las especiales que solo tienen sentido para una clase (get_option_chain, place_option_combo, exercise_option, get_future_continuous, list_future_contracts, get_stock_ticks), la familia de órdenes (list_orders, get_order, place_order, cancel_order, cancel_all_orders), cuenta y posiciones (get_account, get_account_values, list_accounts, list_positions), historial (get_executions, get_completed_orders), y ping más el par original endpoints/request que se queda de red de seguridad para lo que no tenga herramienta dedicada.
Nada de esto es una implementación paralela puesta al lado de la API REST esperando a desincronizarse de ella. Cada herramienta despacha en proceso por los mismos routers, la misma validación, la misma auth por bearer y el mismo rate limiter que una llamada HTTP, así que un agente machacando get_rates quema el mismo presupuesto de pacing histórico de 10 minutos que curl, y se come el mismo sobre 429 RATE_LIMIT_NEAR cuando cruza la raya. Deja api_token vacío y ambas superficies quedan sin autenticar exactamente igual.
Las herramientas que modifican algo llevan su aviso en el propio esquema, que es el único sitio donde el modelo va a mirar seguro. La descripción de place_order termina con «Esto coloca una orden real e irreversible en una cuenta de correduría en vivo, llámalo solo cuando el usuario lo haya pedido explícitamente, y confirma antes los parámetros.» Eso no es una nota al pie en un README que el agente no abrirá jamás; está en la definición de la herramienta, entregada en cada llamada.
Hay también un trozo de realidad de los clientes MCP horneado dentro. El Mount("/mcp", ...) de Starlette sirve en /mcp/* y hace un 307 sobre la forma pelada /mcp, y buena parte de los clientes MCP sencillamente no siguen esa redirección en un POST. En vez de dejar que medio ecosistema se rompa por un tecnicismo de la spec, hay un middleware que reescribe /mcp pelado a /mcp/ antes de enrutar y sigue con su vida.
Para engancharlo a un cliente hay un plugin @psyb0t/ibkr-httpapi, un puente fino de stdio a HTTP apuntado a tu instancia vía IBKR_HTTPAPI_URL (la raíz del servidor; un /v1 al final se recorta, porque todo el mundo se equivoca en eso exactamente una vez) más IBKR_HTTPAPI_TOKEN cuando la auth está puesta. En Claude Code son dos líneas:

claude plugin marketplace add psyb0t/agents
claude plugin install ibkr-httpapi@psyb0t

Lo que pone mucho más nítido el aviso que viene más abajo en esta página: un agente con place_order y cancel_all_orders en su lista de herramientas está a una mala inferencia de hacer algo caro. El esquema le dice que confirme primero. Asegúrate de que algo más se lo diga también.

Auth, Porque Esto No Es un Juguete

Bearer token, comprobado con hmac.compare_digest en vez de un == pelado, porque una comparación ingenua de cadenas filtra información de temporización, con suficientes peticiones puedes bisecar un token carácter a carácter. Pon api_token en config.yaml (o API_TOKEN por env) y cada petición necesita Authorization: Bearer <token> o se lleva un 401 con el sobre de error estándar: {code, message, details}. Deja el token vacío y la API queda abierta de par en par a cualquier cosa que alcance el socket, lo cual está bien en un despliegue solo en loopback detrás de nginx y es una idea malísima en cualquier otro sitio, y la propia documentación del proyecto es tajante al respecto.

Pacing + La Mina de Oro

Cada llamada que va hacia IBKR pasa por un rate limiter preventivo antes de tocar siquiera el socket, porque IBKR corta el acceso a la API por violaciones repetidas de pacing y «perdón, mi bot no lo sabía» no es una defensa que funcione. Tres niveles, cada uno con su propio contador de ventana deslizante más un asyncio.Lock por contrato más un semáforo global de concurrencia: las llamadas de datos historical están topadas a soft-50/hard-55 por ventana de 10 minutos (el límite duro de IBKR está en 60), las de market_data se mantienen bajo el techo de ~50 mensajes por segundo del socket de TWS, y las orders se estrangulan más que ninguna, 5/sec, 3 concurrentes, porque una riada de llamadas de órdenes casi nunca es intencionada. Cruza el tope blando y te llevas un aviso en los logs; cruza el duro y quien llama se come un sobre 429 RATE_LIMIT_NEAR con la regla exacta, el uso, el límite y el retry-after horneados en details.
Detrás de esa misma puerta, todo lo cacheable se escribe a disco bajo data/history/ en cada llamada, velas y ticks van a ficheros CSV por (clase de activo, símbolo, timeframe) con la forma que wickworks ingiere, los detalles de contrato y los metadatos de cadenas de opciones reciben una caché JSON de TTL largo, y cada snapshot de tick o de cadena se engancha a un registro histórico. Nada se borra nunca; está pensado explícitamente como una «mina de oro» de solo añadir, montas ./data, haces copia, y cada llamada que hagas se va acumulando en silencio en un conjunto de datos a largo plazo en vez de tirarse después de leer la respuesta una vez. ¿Necesitas una lectura garantizada fresca en vez de la caché? Cada endpoint cacheable acepta ?refresh=true, que se salta la lectura de caché pero igualmente escribe el resultado fresco de vuelta para que el siguiente que llame se beneficie.

Análisis Técnico Sin Escribir una Librería de Indicadores

El endpoint /rates de cada clase de activo tiene un hermano, POST /<class>/<symbol>/rates/ta, que trae las mismas velas y se las pasa a wickworks, el mismo sidecar de TA que mt5-httpapi ya usa. RSI, MACD, bandas de Bollinger, ADX, ATR, VWAP, Ichimoku, order blocks, fair value gaps, roturas de estructura BOS/CHoCH, estructura de swing, niveles de soporte y resistencia, zonas de liquidez, anclas de sesión, calculado en el servidor, en una sola llamada, sobre velas que ya tienes. Desde la última actualización esto se ha vuelto más listo: el camino de TA se compone ahora con la misma caché de velas que usa /rates en vez de hacer su propia petición aparte, así que una petición de TA repetida contra velas cacheadas cuesta cero presupuesto de pacing de IBKR y aun así vuelve con cálculos de indicadores frescos. wickworks se mantiene estrictamente primitivo por diseño, series en crudo y hechos estructurales, nunca «compra» ni «vende», así que si quieres opiniones te las construyes en tu propio consumidor, no en el sidecar.
Lo apuntas a tu propia instancia con wickworks.url en la config; déjalo vacío y /rates/ta simplemente devuelve un 503 limpio en vez de fingir que funciona. La llamada saliente en sí está restringida por esquema, solo http:// y https://, precisamente para que una URL mal configurada no pueda torcerse en un SSRF contra algo como file://.

Por Qué Demonios una Gateway Sin Interfaz Necesita una Superficie VNC

Pregunta justa, porque IB Gateway no es realmente sin interfaz en el sentido clásico, es una aplicación Java Swing con GUI corriendo bajo un framebuffer virtual (Xvfb) dentro del container. IBC pilota esa GUI por programa para loguearse y clicar más allá de los diálogos del reinicio diario, y se apaña con la abrumadora mayoría de los casos sin ningún humano cerca. Pero IBKR mete palos en las ruedas de vez en cuando: un push semanal de 2FA que caduca, un diálogo inesperado que la automatización de IBC no reconoce, una confirmación de «dispositivo nuevo» la primera vez que levantas una gateway recién hecha. Cuando pasa eso necesitas *ver* de verdad el escritorio que hay detrás de Xvfb, y para eso está Dockerfile.novnc, un proxy websockify pequeño que pone el puerto VNC de la gateway (:5900) por delante sobre HTTP/WebSocket para que puedas mirar (y clicar en) el escritorio de IB Gateway desde una pestaña de navegador normal, sin cliente VNC nativo. No es una máquina virtual completa como el montaje dockurr/windows que mt5-httpapi necesita, no hay sistema operativo que arrancar, es una imagen python:3.12-slim de 57 líneas fijada por digest que corre websockify, y cuyo entrypoint solo mete tu contraseña VNC por plantilla en index.html para que se conecte sola directa a la sesión. Apuntas un navegador cuando algo se atasca, arreglas ese diálogo, cierras la pestaña, y te olvidas de que existe hasta el siguiente hipo semanal de 2FA.

Endurecimiento de Containers, Porque Esto Toca Dinero

Los containers de la API y de wickworks corren con cap_drop: [ALL], sistemas de ficheros raíz read_only: true con montajes tmpfs noexec,nosuid para las partes que necesitan escribir, no-new-privileges:true, y límites de memoria, CPU y pid por servicio. La red se parte en tres redes Docker aisladas: front (nginx hablando con la API), backend (la API hablando con la gateway, que necesita salida hacia el cloud de IBKR), y una red internal: true para el tráfico de la API a wickworks que no tiene ninguna vía de salida, wickworks físicamente no puede llamar a casa ni aunque quisieras. El container de la gateway es la única excepción que no puede correr del todo cerrada (Xvfb más una JVM más IBC escribiendo por todo el sistema de ficheros no tolera una raíz de solo lectura), así que se lleva no-new-privileges como suelo. Todas las imágenes base públicas están fijadas por digest SHA, las dependencias de Python están bloqueadas por hash con uv pip compile --generate-hashes e instaladas con --require-hashes, hay una barrera de edad rodante de 7 días sobre las versiones nuevas de dependencias para que un paquete recién publicado y envenenado en la cadena de suministro no pueda aterrizar el mismo día, y make audit / make audit-go / make audit-compose lanzan pip-audit, govulncheck y un escáner de compose basado en grep respectivamente (ajustes prohibidos como privileged, pid:host, montajes del socket de Docker, tags sin fijar, puertos expuestos públicamente).


La Parte en la Que Esto Maneja una Puta Cuenta de Correduría de Verdad

No voy a enterrar esto en una nota al pie. Esto no es un juguete de datos de mercado, POST /orders coloca una orden real contra una cuenta real de IBKR, y mueve dinero real en el momento en que se acepta. No hay endpoint de modificar órdenes a propósito: para cambiar una orden en reposo la cancelas (DELETE /orders/{orderId}) y colocas una nueva, deliberadamente, en vez de mutar una orden viva sobre la marcha. DELETE /orders sin ID cancela *todas* las órdenes abiertas de la cuenta de golpe. POST /options/exercise ejerce o deja expirar contratos reales. Ninguno de estos tiene botón de deshacer. Si vas a apuntar un agente o un script a este chisme, haz que le confirme a un humano el símbolo resuelto, el sentido, la cantidad y el precio antes de disparar nada que modifique, y nunca le dejes reintentar automáticamente una orden rechazada, un rechazo es una señal de stop, no un bug que esquivar.
Dos cosas suavizan el radio de explosión si las quieres: TRADING_MODE=paper en .env.ibkr conecta a la gateway de paper trading de IBKR (puerto 4002) en vez de la de live (4001), los números de cuenta de paper empiezan por DU, los de live por U, y /accounts te dirá con cuál estás hablando en realidad. Y IBC soporta READ_ONLY_API=yes, que bloquea la API de trading entera a nivel de gateway si todo lo que quieres de este chisme son datos de mercado y visibilidad de la cuenta. Deja api_token vacío y toda esta superficie, datos de mercado, posiciones y colocación de órdenes por igual, queda sin autenticar para cualquier cosa que alcance el puerto. Pon el token. Escucha en loopback. No seas tú el motivo de que el script de otro coloque una orden en tu cuenta.

En Resumen

Si mt5-httpapi era «haz que MetaTrader 5 hable HTTP aunque Windows esté en medio», ibkr-httpapi es la versión en la que Windows nunca estuvo en medio, IB Gateway simplemente corre en Linux como una pieza normal de software de servidor, así que todo el stack son containers, una API con la spec primero y clientes generados en dos lenguajes, 24 herramientas MCP tipadas que pasan por esos mismos handlers para cuando conduce un agente, pacing que evita que IBKR te banee, una caché en disco que convierte cada llamada en datos permanentes en vez de JSON tirado, y el mismo sidecar de TA wickworks haciendo las cuentas de indicadores en el servidor. Seis clases de activos, un bearer token, cero VM de Windows. Cógelo en github.com/psyb0t/ibkr-httpapi, léete las notas de licencia antes de construir la imagen de la gateway, y pon un token de API antes de exponer esto a algo que no sea localhost. Está bajo licencia WTFPL, haz lo que te dé la gana con ello, solo no me eches la culpa cuando tu bot compre 500 puts del ticker equivocado.