MetaTrader 5 solo corre en Windows. La librería oficial de Python solo funciona en Windows. El lenguaje de scripting MQL5 es una imitación de C++ de 2005 que te da ganas de sacarte los ojos con un tenedor oxidado. Y si quieres hacer cualquier cosa programática con él, sacar velas, meter órdenes, mirar posiciones, se espera que o escribas MQL5 o corras Python en una máquina Windows con el terminal abierto.
Yo quería pegarle a un endpoint HTTP desde cualquier máquina, en cualquier lenguaje, y recibir JSON de vuelta. Como un ser humano normal.
Así que construí mt5-httpapi. Una VM de Windows de verdad corriendo dentro de Docker vía QEMU/KVM, con el terminal MT5 completo en modo portable y una API REST en Flask encima. Sin Wine, sin apaños de emulación, sin chapuzas. Un entorno Windows 11 legítimo ejecutando el binario real de MetaTrader 5, accesible por HTTP/JSON pelado desde donde sea.
Varios brókeres. Varias cuentas. Cada terminal tiene su propio proceso de API dentro de la VM, y un sidecar de nginx siempre encendido los pone a todos detrás de un único puerto del host en http://localhost:8888/<broker>/<account>/.... Corre dos challenges de FTMO a la vez, o mezcla brókeres, o levanta diez terminales en una máquina, lo que necesites.
Cómo Funciona Esta Aberración
El container ejecuta dockurr/windows, una imagen de Docker que arranca una VM de Windows completa usando virtualización por hardware QEMU/KVM. En el primer arranque descarga tiny11 (un Windows 11 desnudado, ~4 GB), lo instala, luego monta Python 3.12 solo, instala MetaTrader5, le quita a Windows toda la mierda de encima, elimina Defender entero y lo arranca todo.
Tras el primer arranque (~10 minutos), los siguientes tardan cosa de un minuto. El container va configurado con 2 vCPUs, 512 MB de RAM real y 5 GB de swap. Suena maldito, funciona perfecto. tiny11 más el script de limpieza se queda en ~1.4 GB en reposo, y MT5 más la API de Python casi no suman nada. Windows y MT5 no son lo bastante sensibles a la latencia como para que el swap se note.
Una carpeta compartida entre el host y la VM de Windows (/shared → C:UsersDockerDesktopShared) guarda todo: scripts, configuraciones, instaladores de bróker, el código del servidor de API y los logs. El script run.sh sincroniza todo en esa carpeta, genera reglas de NAT con iptables para el reenvío de puertos del container a la VM, y levanta docker-compose.
noVNC en el puerto 8006 te da una vista del escritorio de Windows desde el navegador. Útil para mirar cómo avanza la instalación y confirmar que arrancó todo. Después de eso, olvídate de que existe la interfaz y pégale solo a la API REST.
Varios Terminales Detrás de Un Solo Puerto
Cambio de arquitectura (v4.0+): la disposición original exponía cada terminal en su propio puerto del host (6542, 6543, 6544…). Se acabó. Ahora todo vive detrás de un único puerto del host (por defecto 127.0.0.1:8888) con un sidecar de nginx siempre encendido delante que enruta por prefijo de ruta:
http://localhost:8888/<broker>/<account>/...v4.4 extendió eso a /<broker>/<account>/<instance>/..., dejando la forma simple /<broker>/<account>/ como alias de la instancia por defecto, así que las URL de siempre siguen funcionando. La forma no distingue de modo: los terminales live y los de backtest se enrutan igual.
Un puerto reenviado desde el host, una sola superficie TLS de la que preocuparse, una sola regla en tu firewall. Cada petición pega en nginx, el prefijo /<broker>/<account>/ se recorta, y el resto se pasa al proceso de API de Python de ese terminal, dentro de la VM, por el bridge de docker. La config de nginx se genera sola desde config.yaml en cada make up, así que añadir o quitar un terminal no pide tocar el reverse-proxy a mano.
Consolidación de config (v4.0): la vieja división accounts.json + terminals.json desapareció. Ahora hay un solo config/config.yaml (en gitignore) como fuente de verdad:
# Bearer token for API auth. Empty = no auth.
api_token: "paste-the-output-of-openssl-rand-hex-32-here"
# VM auto-reboot every N minutes (flushes DWM/VirtIO-GPU state). 0 = disable.
reboot_interval: 30
# Default Strategy Tester timeout. POST /backtest can override per job.
backtest_timeout: "6h"
tailscale:
auth_key: "" # tskey-auth-... — empty disables the tailscale sidecar
login_server: "" # Headscale URL; empty = Tailscale cloud
# Extra pip packages installed in the VM.
requirements: []
# Broker credentials, organized by broker → account name.
accounts:
roboforex:
main:
login: 12345678
server: "RoboForex-Pro"
password: "your_password"
demo:
login: 87654321
server: "RoboForex-Demo"
password: "demo_password"
# Terminal instances — one MT5 + one API process per entry.
terminals:
- broker: roboforex
account: main
port: 6542 # container-internal only, not exposed to host
utc_offset: "3h"
- broker: roboforex
account: demo
port: 6543
utc_offset: "3h"
- broker: roboforex
account: tester
port: 6544
utc_offset: "3h"
mode: backtest # don't auto-launch terminal64.exe — reserved for /backtest jobs
symbol_suffix: ".r" # explicit suffix for tester symbol remap (e.g. EURUSD → EURUSD.r)El campo broker cuadra tanto con la clave de accounts: como con el nombre del fichero del instalador (mt5setup-roboforex.exe, mt5setup-ftmo.exe). El terminal de cada bróker se instala una vez en <broker>/base/, y luego se copia a <broker>/<account>/ al arrancar, para que varias cuentas del mismo bróker no se pisen.
utc_offset va por terminal porque los brókeres viven en husos raros (RoboForex/FTMO en UTC+3, TeleTrade en UTC+2). Cada timestamp que sale por el cable, velas, ticks, historial, posiciones, se normaliza a UTC de verdad en el servidor. Tu cliente no vuelve a pensar nunca en la hora local del bróker. port es el puerto interno del container con el que habla nginx; no se expone al host.
reboot_interval es la nueva palanca de reinicio automático de la VM: la pila DWM/VirtIO-GPU de MetaQuotes va acumulando estado en el kernel con uptimes largos y acaba poniéndose rara, así que la VM se reinicia con un horario (cada 30 minutos por defecto). Pon 0 para desactivarlo.
mode por terminal (v4.3): live es el valor por defecto, terminal64.exe se queda corriendo para que el SDK de MT5 esté inicializado para los endpoints de trading en vivo. backtest prepara el mismo directorio portable de datos pero no lanza terminal64.exe, dejando el directorio libre para un subproceso del Strategy Tester. MT5 es de instancia única por directorio portable de datos, y ahí está el detalle: si terminal64.exe ya está corriendo, un subproceso del tester contra el mismo directorio sale en silencio con código 0 y no produce ningún informe. Levanta un roboforex/tester dedicado junto a tu roboforex/main en vivo y tienes backtests por HTTP sin cargarte el SDK en vivo.
symbol_suffix se ocupa de los brókeres que renombran todo en el tester. Si el tuyo usa EURUSDp, EURUSD.p, EURUSD-mini o el sufijo de mierda que se les haya ocurrido en su pool de símbolos del Strategy Tester, lo pones aquí y mt5-httpapi lo añade solo cuando [Tester].Symbol en el INI no lo trae ya. Cadena vacía = sin sufijo. backtest_timeout es el tope por defecto de POST /backtest, misma gramática de duración que utc_offset ("6h", "30m", "3h30m", los números pelados se tratan como horas), y se puede sobrescribir por trabajo.
Cuatro terminales corriendo a la vez sobre 2 vCPUs con 512 MB de RAM real: la CPU se dispara al 100% durante el arranque, mientras se inicializa todo, y luego cae a ~15% en reposo. Memoria total: 2.1 GB, toda absorbida por el swap. Podrías levantar 10+ terminales así sin despeinarte, siempre que no estés rascando historial profundo en todos a la vez (MT5 cachea cada gráfico que carga y no lo suelta jamás; los rellenos profundos revientan el límite de 512 MB por abajo).
Instalación
Requisitos: host Linux con KVM activado (/dev/kvm), Docker + Compose, ~20 GB de disco, 5 GB de RAM.
# Clone it
git clone https://github.com/psyb0t/mt5-httpapi
cd mt5-httpapi
# Single config file now — copy and edit
cp config/config.yaml.example config/config.yaml
# Set api_token, accounts, terminals
# Drop your broker's MT5 installer
cp ~/Downloads/mt5setup.exe mt5installers/mt5setup-roboforex.exe
# Fire it up
make upEl primer arranque descarga la ISO de Windows, la instala, hace la limpieza, instala MT5, reinicia un par de veces y luego arranca todo. Después de eso:
make up # start
make down # stop
make logs # tail logs
make status # check VM and API status
make clean # nuke VM disk (keeps ISO)
make distclean # nuke everything including ISOAutenticación
La API corre abierta por defecto. Si la vas a exponer en una red (aunque sea local, con otras máquinas dentro), pon el token en config/config.yaml:
api_token: "$(openssl rand -hex 32)"Si api_token no está vacío, cada endpoint exige Authorization: Bearer <token>. Cadena vacía = sin autenticación, lo cual va bien para un montaje de una sola máquina donde nada más alcanza el puerto.
Con la autenticación activada, pon el token en tu shell e inclúyelo en cada petición:
export MT5_API_TOKEN=$(grep ^api_token config/config.yaml | awk -F'"' '{print $2}')
curl -H "Authorization: Bearer $MT5_API_TOKEN"
http://localhost:8888/roboforex/main/ping
# {"status": "ok"}Todos los ejemplos de curl de abajo asumen que no hay autenticación. Si configuraste un token, añade -H "Authorization: Bearer $MT5_API_TOKEN" delante de cada uno.
La API
Todos los terminales se sirven detrás de un único puerto del host vía nginx. Punto de entrada por defecto: http://localhost:8888 (solo loopback). Cada terminal vive en su propio prefijo de ruta, http://localhost:8888/<broker>/<account>/.... Los ejemplos de abajo usan roboforex/main; pon los tuyos. GET para leer, POST para crear, PUT para modificar, DELETE para cerrar. Todo JSON.
Salud y Terminal
# Health check
curl http://localhost:8888/roboforex/main/ping
# {"status": "ok"}
# Last MT5 error
curl http://localhost:8888/roboforex/main/error
# {"code": 1, "message": "Success"}
# Terminal info (connected, trade_allowed, build, company)
curl http://localhost:8888/roboforex/main/terminal
# Force re-init, shutdown, or restart
curl -X POST http://localhost:8888/roboforex/main/terminal/init
curl -X POST http://localhost:8888/roboforex/main/terminal/shutdown
curl -X POST http://localhost:8888/roboforex/main/terminal/restartLa API se inicializa sola en la primera petición. Si MT5 todavía no está conectado, un hilo de fondo lo reintenta cada 30 segundos. Casi nunca vas a necesitar llamar a /terminal/init a mano.
Un monitor de salud corre en segundo plano, cada 60 segundos comprueba si el terminal está vivo, logueado y con el algo trading activado. Si el terminal lleva 5 comprobaciones seguidas muerto, se reinicia solo: mata el proceso, relanza el terminal, espera a que el diario confirme que está arriba y reconecta la API. También puedes disparar un reinicio manual con POST /terminal/restart.
Ese monitor vive dentro de la VM, lo que significa que solo puede arreglar lo que la VM siga lo bastante sana como para arreglar. v4.13.0 añadió el piso de encima: un sidecar vm-watchdog gestionado por Compose, que vigila los containers de la VM de Windows desde el host, por el socket de Docker, y recrea uno por la ruta existente recreate-vm.sh cuando lleva enfermo el tiempo suficiente como para que signifique algo. No a la primera comprobación fallida. WATCHDOG_MIN_FAILING_STREAK son 10 fallos seguidos por defecto, a intervalos de 30 segundos, y luego se echa atrás exponencialmente, 5 minutos, 15 minutos, una hora, y se rinde tras 3 intentos. Una VM que se recupera sola se deja en paz.
El filtro es la parte interesante. WATCHDOG_IMAGE_FILTER es dockurr/windows por defecto, y un valor vacío se rechaza en vez de tratarse como “coincide con todo”, porque la versión que trata un filtro en blanco como un comodín es la versión que te recrea todos los containers del proyecto a las 4 de la mañana.
También supervisa los sidecars que comparten el espacio de nombres de red de una VM (network_mode: service:<vm>), una vez que la VM lleva sana de forma continuada. Un sidecar varado en un espacio de nombres obsoleto se repara solo, sin recrear la VM de debajo. Ese salió de dos días de llamadas de TA muertas: la VM estaba bien, el sidecar de wickworks apuntaba a un espacio de nombres de red que ya no existía, y nadie se enteró porque los health checks de la propia VM estuvieron en verde todo el rato. WATCHDOG_WATCH_SIDECARS=0 si quieres volver a la recuperación solo de VM. Hay 15 variables WATCHDOG_* en total, incluida una WATCHDOG_DRY_RUN para que mires lo que habría hecho antes de dejarle hacer nada.
Cuenta
curl http://localhost:8888/roboforex/main/account{
"login": 12345678,
"balance": 10000.0,
"equity": 10000.0,
"margin": 0.0,
"margin_free": 10000.0,
"leverage": 500,
"currency": "USD",
"trade_allowed": true,
"margin_so_call": 70.0,
"margin_so_so": 20.0
}Datos de Mercado
# List all symbols (or filter)
curl http://localhost:8888/roboforex/main/symbols
curl "http://localhost:8888/roboforex/main/symbols?group=*USD*"
# Symbol details (bid, ask, spread, contract size, tick value, lot constraints)
curl http://localhost:8888/roboforex/main/symbols/EURUSD
# Latest tick
curl http://localhost:8888/roboforex/main/symbols/EURUSD/tick
# OHLCV candles
curl "http://localhost:8888/roboforex/main/symbols/EURUSD/rates?timeframe=H4&count=100"
# OHLCV + indicators in one shot (server-side TA via wickworks)
curl -X POST "http://localhost:8888/roboforex/main/symbols/EURUSD/rates/ta?timeframe=H1&count=200"
-H "Content-Type: application/json"
-d '{"indicators":{"rsi":true,"macd":true,"bbands":{"length":20,"std":2}}}'
# Tick history
curl "http://localhost:8888/roboforex/main/symbols/EURUSD/ticks?count=100"Marcos temporales: M1 M2 M3 M4 M5 M6 M10 M12 M15 M20 M30 H1 H2 H3 H4 H6 H8 H12 D1 W1 MN1. El time de una vela es su hora de apertura, en segundos unix epoch.
Meter Órdenes
# Market buy
curl -X POST http://localhost:8888/roboforex/main/orders
-H "Content-Type: application/json"
-d '{"symbol": "ADAUSD", "type": "BUY", "volume": 1000, "sl": 0.25, "tp": 0.35}'
# Pending buy limit
curl -X POST http://localhost:8888/roboforex/main/orders
-H "Content-Type: application/json"
-d '{"symbol": "ADAUSD", "type": "BUY_LIMIT", "volume": 1000, "price": 0.28, "sl": 0.25, "tp": 0.35}'Campos obligatorios: symbol, type, volume. El precio se autocompleta en las órdenes a mercado. Tipos de orden: BUY, SELL, BUY_LIMIT, SELL_LIMIT, BUY_STOP, SELL_STOP, BUY_STOP_LIMIT, SELL_STOP_LIMIT. Políticas de llenado: FOK, IOC (por defecto), RETURN. Caducidad: GTC (por defecto), DAY, SPECIFIED, SPECIFIED_DAY.
Cada operación de trading devuelve un resultado con retcode, 10009 significa éxito, cualquier otra cosa significa que algo salió mal. Usa GET /error para depurar.
Gestionar Posiciones y Órdenes
# List open positions
curl http://localhost:8888/roboforex/main/positions
curl "http://localhost:8888/roboforex/main/positions?symbol=EURUSD"
# Move SL/TP
curl -X PUT http://localhost:8888/roboforex/main/positions/12345
-H "Content-Type: application/json"
-d '{"sl": 0.27, "tp": 0.36}'
# Close full position
curl -X DELETE http://localhost:8888/roboforex/main/positions/12345
# Partial close
curl -X DELETE http://localhost:8888/roboforex/main/positions/12345
-H "Content-Type: application/json"
-d '{"volume": 500}'
# Modify pending order
curl -X PUT http://localhost:8888/roboforex/main/orders/67890
-H "Content-Type: application/json"
-d '{"price": 0.29, "sl": 0.26, "tp": 0.36}'
# Cancel pending order
curl -X DELETE http://localhost:8888/roboforex/main/orders/67890Historial
# Order history (last 24h)
curl "http://localhost:8888/roboforex/main/history/orders?from=$(date -d '1 day ago' +%s)&to=$(date +%s)"
# Deal history (last 24h)
curl "http://localhost:8888/roboforex/main/history/deals?from=$(date -d '1 day ago' +%s)&to=$(date +%s)"from y to son obligatorios, en segundos unix epoch. Los deals llevan entry (0 = apertura, 1 = cierre) y profit (0 en las entradas, P&L realizado en las salidas).
Análisis Técnico
Dos formas de ponerle indicadores a tus velas, en el servidor o a mano.
TA en servidor: POST /symbols/:symbol/rates/ta (v4.1+)
Una sola llamada HTTP y las velas vuelven ya enriquecidas con indicadores. El container de mt5 trae un sidecar de TA wickworks encerrado en el espacio de nombres de red de mt5, sin puertos publicados, sin despliegue aparte, sin tráfico externo. Los mismos parámetros de query que GET /rates (timeframe, count, from, to), más un cuerpo JSON que le dice a wickworks qué indicadores calcular:
curl -X POST "$MT5_API_URL/symbols/EURUSD/rates/ta?timeframe=H1&count=200"
-H "Content-Type: application/json"
-d '{
"indicators": {
"rsi": true,
"rsi21": {"type": "rsi", "length": 21},
"macd": true,
"bbands": {"length": 20, "std": 2}
}
}'La respuesta lleva las velas crudas y la salida de wickworks como hermanas:
{
"symbol": "EURUSD",
"timeframe": "H1",
"bars": [ { "time": 1771146000, "open": 1.0832, "high": 1.0840, "low": 1.0828, "close": 1.0835, ... } ],
"ta": { "indicators": { "rsi": [...], "macd": {...}, "bbands": {...} } }
}Cada entrada bajo indicators asocia una clave de salida o bien a true (los valores por defecto) o bien a un objeto plano de parámetros. Solo añades "type": "<name>" cuando la clave de salida difiere del nombre del indicador, por ejemplo para correr un segundo RSI bajo rsi21. El catálogo de wickworks cubre a los sospechosos habituales (RSI, MACD, bandas de Bollinger, ADX, VWAP, Ichimoku, ATR, Estocástico, MFI, decenas de medias móviles) más las primitivas de Smart Money (order blocks, fair value gaps, BOS, CHoCH, estructura de swing, niveles S/R). Solo primitivas, las señales interpretativas (divergencias, eventos de cruce) se quedan en tu consumidor. Lista completa de tipos, parámetros y formas de salida en github.com/psyb0t/docker-wickworks.
La URL del sidecar se configura con wickworks: en config.yaml, por defecto http://20.20.20.1:8000/, la IP de la pasarela de dockurr tal como se ve desde dentro de la VM de Windows.
TA en cliente: sacas velas crudas y las trituras tú
Si quieres control total o ya estás metido hasta el cuello en pandas, el repo trae un ejemplo completo en examples/python/ que saca velas con GET /rates y las pasa por pandas-ta y smartmoneyconcepts.
Indicadores incluidos: EMA 21, SMA 50/100/200, ATR, RSI, MACD, bandas de Bollinger, MFI, Estocástico, ADX, VWAP, más Smart Money Concepts: order blocks, fair value gaps, break of structure, change of character y niveles de liquidez.
# TA report with signal detection
python ta.py # EURUSD H4 200 candles (default)
python ta.py BTCUSD H1 100 # custom symbol/timeframe/count
python ta.py ADAUSD D1 200
# 1920x1080 candlestick chart with all overlays
python chart.py ADAUSD
python chart.py BTCUSD H1 100
python chart.py EURUSD D1 200 -o eurusd.pngEl informe de TA escupe los valores de la última vela para cada indicador y luego corre la detección de señales: RSI sobrecomprado/sobrevendido, cruces del histograma del MACD, golden/death cross de EMA/SMA, roturas de bandas de Bollinger, extremos del Estocástico, fuerza de tendencia por ADX. El gráfico renderiza velas en tema oscuro con medias móviles, bandas de Bollinger, VWAP, overlays de SMC (order blocks, FVG, líneas BOS/CHoCH, barridos de liquidez), panel de RSI y panel de MACD. PNG de calidad de publicación a 1920×1080.
Los módulos de indicadores y de señales están pensados como piezas sueltas. Importas add_rsi(df) o detect_signals(df) en tus propios scripts y usas la API como fuente de datos. Sacas velas, aplicas el análisis que quieras, metes operaciones, todo desde un script de Python corriendo en cualquier máquina.
Cliente de Go: GetRatesTA (v4.2+)
El cliente tipado de Go en clients/go/ envuelve el endpoint de wickworks con su propio método:
resp, err := c.GetRatesTA(ctx, "EURUSD",
mt5.RatesQuery{Timeframe: "H1", Count: 200},
map[string]any{
"indicators": map[string]any{
"rsi": true,
"macd": true,
"bbands": map[string]any{"length": 20, "std": 2},
},
},
)El mismo cliente cubre GetRates, GetTicks, GetAccount, CreateOrder, ListPositions, UpdatePosition, ClosePosition, los endpoints de historial y las llamadas del ciclo de vida del terminal. Los errores mapean a centinelas tipados contra los que puedes hacer errors.Is(), aichteeteapee.ErrUnauthorized para un 401, aichteeteapee.ErrBadRequest para un 400, y un mt5httpapi.ErrNotInitialized dedicado para el 503 que verás mientras la VM sigue arrancando y el SDK de MT5 no está listo.
Strategy Tester / Backtesting (v4.3+)
Hacerle backtest a un EA en MT5 normalmente significa ir clicando por la ventana del Strategy Tester como un animal. v4.3 mete todo eso en la API HTTP: subes un INI, un experto .ex5, opcionalmente un fichero de parámetros .set, recibes un jobId, consultas hasta que termine, y te bajas el informe HTML y el log del terminal. El mismo flujo que harías en la interfaz, pero sin cabeza y scriptable.
Por qué mode: backtest en su propio terminal. MT5 es de instancia única por directorio portable de datos. Si terminal64.exe ya está ahí corriendo para sostener el SDK en vivo, un subproceso del Strategy Tester contra el mismo directorio sale en silencio con código 0 y no obtienes nada. Así que dedicas un terminal en config.yaml con mode: backtest, recibe la misma instalación portable, las mismas credenciales de bróker, pero sin terminal64.exe autolanzado. El subproceso del tester tiene uso exclusivo del directorio de datos y produce un informe de verdad. Córrelo junto a tus terminales en vivo; no se ven entre ellos.
Flujo en dos etapas. Primero POST /backtest/build-ini convierte una especificación JSON pequeña en un tester.ini completo (sin credenciales, sin resolución de ruta del experto, es un ayudante sin estado, también puedes escribir el INI a mano). Luego POST /backtest recibe una subida multipart con el INI más el experto, encola el trabajo y devuelve un jobId.
export URL=http://localhost:8888/roboforex/tester
export TOK=$MT5_API_TOKEN
# 1. Build the INI: 5-year NZDJPY M15 open-prices run with 5 ms latency.
curl -sS -X POST "$URL/backtest/build-ini"
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json"
-d '{
"symbol": "NZDJPY",
"timeframe": "M15",
"expert": "EA Studio NZDJPY M15 1615044595.ex5",
"lastYears": 5,
"modelling": "open-prices",
"latencyMs": 5,
"expertParameters": "ea studio nzdjpy m15 1615044595.set"
}' > tester.ini
# 2. Submit using a host-managed expert + set file from assets/.
JOB=$(curl -sS -X POST "$URL/backtest"
-H "Authorization: Bearer $TOK"
-F "[email protected]"
-F "expert_name=EA Studio NZDJPY M15 1615044595.ex5"
-F "set_name=ea studio nzdjpy m15 1615044595.set"
| jq -r .jobId)
# 3. Poll until done.
while :; do
STATUS=$(curl -sS -H "Authorization: Bearer $TOK" "$URL/backtest/$JOB" | jq -r .status)
echo "$STATUS"
[[ "$STATUS" == completed || "$STATUS" == failed ]] && break
sleep 30
done
# 4. Fetch the report + terminal log.
curl -sS -H "Authorization: Bearer $TOK" "$URL/backtest/$JOB/report" -o report.htm
curl -sS -H "Authorization: Bearer $TOK" "$URL/backtest/$JOB/log" -o tester.logEl experto y el fichero de set se pueden mandar inline (preferible para tiradas sueltas) o referenciar por nombre desde un pool gestionado en el host, assets/experts/*.ex5 y assets/sets/*.set, montados en solo lectura dentro de la VM en /shared/assets. El path traversal en expert_name / set_name se rechaza.
Lo que el servidor hace por ti. [Common] Login / Password / Server del INI que subes se sobrescriben con las credenciales de config.yaml para ese bróker/cuenta, así que el INI que subes no necesita conocer tus credenciales reales y no las comiteas a un repo sin querer. La ruta del experto se reescribe a Uploaded<basename>. El fichero de set recibe un espacio de nombres por trabajo para evitar colisiones. Si tu bróker usa símbolos con sufijo (EURUSDp, EURUSD.p, EURUSD-mini), el symbol_suffix configurado se añade solo a [Tester].Symbol cuando falta.
El payload de estado. GET /backtest/<jobId> devuelve status ∈ queued / running / completed / failed. Al completarse, se extrae un objeto summary del informe HTML, netProfit, profitFactor, recoveryFactor, expectedPayoff, sharpeRatio, maxDrawdown, totalTrades, profitTrades, lossTrades, para que puedas ordenar las tiradas por programa sin parsear HTML tú mismo.
Concurrencia. Solo corre un tester a la vez por proceso de API, serializado por un lock interno. Los envíos de más hacen cola. El timeout por defecto sale de backtest_timeout en config.yaml (6h por defecto si no está puesto); se sobrescribe por trabajo con el campo multipart timeout. Si la API se reinicia con un backtest en vuelo, el trabajo huérfano se marca failed con API restarted before completion en el siguiente arranque, sin zombis.
Optimización e Instancias de Terminal (v4.4)
v4.3 te dio backtests de una pasada con informe HTML. Esa es la mitad fácil. Las tiradas de optimización son otro animal: Tester.Optimization en 1 o 2 escupe un informe XML de optimización, y el modo 3, “todos los símbolos”, escupe un .symbols.xml más una caché binaria .opt que MT5 no expone por ninguna API documentada. v4.4 parsea las dos.
POST /backtest/build-ini acepta ahora optimization (0..3) y optimizationCriterion (0..7) junto a los campos de una pasada de v4.3. Y hay un nuevo POST /backtest/build-set que escupe un fichero .set de Strategy Tester desde una lista JSON de parámetros, tanto valores fijos como rangos de optimización (start / step / stop / optimize), en el formato nativo de MT5 name=value||.... Así puedes generar el barrido de parámetros por programa en vez de editar ficheros de set a mano en la interfaz.
GET /backtest/<jobId> gana tres campos en las tiradas de optimización: optimizationType (0..3), optimizationResults (las N mejores pasadas, ordenadas) y optimizationCache, metadatos del parseo del .opt: nombre de perfil, offsets de bytes, build de MT5, número de símbolos. Los tres valen None en tiradas que no son de optimización. POST /backtest acepta un topPasses opcional (1..500, 50 por defecto) para limitar cuántas filas vuelven.
El parser de la caché es la parte divertida. mt5api/backtest/cache_parser.py hace ingeniería inversa del formato binario .opt de MT5. Va guiado por perfiles en vez de estar cableado: se puntúan disposiciones candidatas de offsets de bytes contra el fichero real y gana la que mejor encaja. Cuando MT5 saca un build que mueve la disposición, añades un candidato a OPT_CACHE_PROFILE_CANDIDATES, sin rutas de código por build, sin olisquear versiones.
Tail de log en vivo. GET /backtest/<jobId>/tail?lines=N mezcla el log de la tirada (stdout de terminal64.exe), el diario del terminal MT5 y el sub-log del Strategy Tester en un solo flujo. Acotado a 10..1000 líneas. Funciona mientras el trabajo está en cola, corriendo o terminado, así que puedes mirar una optimización de seis horas en vez de quedarte fijo en status: running rezando.
Instancias de terminal. Una entrada de terminals[] acepta ahora un nombre de instance opcional, que deja que el mismo par bróker/cuenta aparezca más de una vez, con puertos distintos y directorios portables de datos distintos. Ese es todo el sentido: un terminal en vivo puede convivir con uno o más terminales de backtest sobre el mismo login. Si lo omites se resuelve a default, manteniendo el alias de ruta de siempre. Varias entradas sobre el mismo par bróker/cuenta necesitan valores de instancia distintos o chocan en terminals/<broker>/<account>/<instance>/.
Dimensionar la Posición
El endpoint de símbolo te da todo lo que hace falta para calcular tamaños de posición decentes:
risk_amount = balance * risk_pct
sl_distance = ATR * multiplier
ticks_in_sl = sl_distance / trade_tick_size
risk_per_lot = ticks_in_sl * trade_tick_value
volume = risk_amount / risk_per_lotRedondeas hacia abajo a volume_step, acotas a [volume_min, volume_max]. Comprobación de sentido común: volume * trade_contract_size * price debería cuadrar con tu saldo. Un lote de EURUSD son 100.000 EUR, no 1 EUR, y trade_contract_size te lo dice. Compruébalo antes de fundirte la cuenta entera sin querer en lo que creías que era una posición micro.
Skill de IA
El repo trae un directorio .agents/skills/ con una definición de skill para agentes de código con IA. Funciona en cualquier cosa que lea .agents/skills/, OpenClaw incluido, y se instala de forma nativa en Claude Code y Codex desde un único marketplace compartido:
claude plugin marketplace add psyb0t/agents
claude plugin install mt5-httpapi@psyb0tCodex usa el mismo marketplace con otro verbo, codex plugin marketplace add psyb0t/agents y luego codex plugin add mt5-httpapi@psyb0t, porque codex plugin install no existe. Codex además pilla el skill él solo en un checkout del repo, ya que escanea .agents/skills/ de forma nativa y no hay instalación de por medio. Lo apuntas a tu instancia en marcha con MT5_API_URL, añades MT5_API_TOKEN si la auth está puesta, y el agente recibe la referencia completa de la API, la checklist de seguridad previa a operar, las fórmulas de dimensionado de posición y los patrones de uso. Sabe qué endpoints existen, qué campos mirar antes de meter una operación y cómo interpretar los resultados.
Lo que significa que le puedes decir a tu agente de IA “compra 0.1 lotes de EURUSD con un stop loss a 2 ATR” y tiene todo lo que necesita para sacar la info del símbolo, calcular el precio del SL, meter la orden y verificar el resultado. Sin leerse documentación de API a mano, el skill le da el manual entero.
MCP: Un Endpoint, Todos los Terminales
Un skill le enseña a un agente a manejar la API REST. v4.7 fue más lejos y le dejó saltarse la capa HTTP del todo: cada terminal monta su propio servidor MCP en /<broker>/<account>/mcp/, al lado de la API REST, en el mismo proceso. nginx recorta el prefijo y pasa el resto tal cual, así que la URL alcanzable es simplemente la base normal del terminal más /mcp/.
Arrancó como un passthrough genérico, una sola herramienta request capaz de llamar a cualquier endpoint, y v4.8 la sustituyó por ~24 herramientas tipadas dedicadas agrupadas por familia: datos de mercado, cuenta, posiciones, órdenes, historial, terminal, backtest. El nombre de cada herramienta, sus parámetros tipados y su descripción son lo que lee el agente, así que se acabó adivinar rutas crudas. Cada herramienta pasa exactamente por el mismo handler, la misma auth y el mismo bloqueo de MT5 que una petición HTTP real. La request genérica más un catálogo endpoints se quedaron como red de seguridad. Una omisión deliberada: enviar un backtest es una subida multipart, así que se queda solo en REST, get_backtest consulta estado, informe y log, pero las tiradas nuevas van por POST /backtest.
Lo que dejaba una limitación sinceramente molesta. Una sesión MCP tiene un catálogo de herramientas fijo, así que no hay hueco por llamada para nombrar una cuenta, una sesión quedaba atada de forma permanente al terminal con el que conectó. Seis terminales significaban seis sesiones. v4.9 lo arregla con un servicio mcpunifier: un container de Linux sentado junto a la VM de Windows, que sirve esas mismas herramientas en un único /mcp con parámetros broker y account, más una herramienta list_terminals para que quien llame descubra qué hay configurado y qué cuentas están vivas antes de hacer nada.
La URL que le das al cliente es lo que decide tu radio de explosión:
http://host:8888/<broker>/<account>/mcp/ one terminal, no account param to get wrong
http://host:8888/mcp/ every terminal, + broker/account + list_terminalsEl unificador lee el mismo config/config.yaml que genera el enrutado de nginx, lo que significa que físicamente no puede enrutar a donde nginx no enruta, y despacha directo al puerto de cada terminal. La tabla de enrutado se resuelve una vez al arrancar y no se vuelve a sondear, así que el servicio nunca se queda esperando a un terminal, un terminal caído solo tumba las llamadas que lo nombran en vez de llevarse por delante a las demás, y cada respuesta buena lleva el terminal que contestó de verdad. Pide un par bróker/cuenta que no esté configurado y te lo rechaza con la lista de lo que podrías haber pedido, en vez de enrutarte en silencio a algo plausible pero equivocado. Nada de lo que había cambió: los endpoints por terminal y toda la superficie REST siguen intactos.
v4.9.1 es de esos bugs que merece la pena dejar escritos. La config de nginx que generaba v4.9.0 llevaba un proxy_pass http://mcpunifier:6600/ literal, y nginx resuelve un hostname literal de upstream mientras parsea la config, no en el momento de la petición. Así que en cualquier despliegue sin ese container, nginx abortaba con host not found in upstream "mcpunifier", y cada ruta por terminal más toda la API REST detrás se iban a 502. Como docker-compose.yml está en gitignore, bajarte v4.9.0 te daba el generador nuevo sin el servicio al que referencia, y el siguiente reinicio te tumbaba el stack. Ahora un unificador ausente es solo un unificador ausente. v4.9.2 añadió un arnés de punta a punta para eso como script de shell, que v4.10 luego retiró, sus siete aserciones se mudaron sin cambios a tests/integration/test_mcpunifier.py, así que el proyecto corre un solo arnés de integración en un solo lenguaje en vez de un script de shell al lado. Ahora es make test-integration, una suite de pytest respaldada por containers que arranca un nginx real contra la config generada, con una VM ausente a propósito.
Y resultó que aquel bug de nginx tenía un hermano mayor. La misma resolución en tiempo de parseo del proxy_pass literal que tumbó el stack por un unificador ausente lo hacía también con las rutas de terminal: un solo container de VM ausente y nginx no arrancaba en absoluto, llevándose por delante las rutas de todas las VM sanas, la API REST y /mcp/. v4.10 hizo que las rutas de terminal resuelvan su upstream por petición, que es también lo que hizo posible lo siguiente.
v4.10 pasó a multi-VM. Un vms.yaml declara los recursos de cada VM de Windows y cada terminal se ata a una por un campo nuevo vm:; config_helper.py genera rutas de nginx apuntando al container de la VM propietaria, run.sh recorre cada VM para el DNAT y los ficheros de grupo por VM, y docker-compose.yml se renderiza desde una plantilla Jinja. Sin vms.yaml se queda en una sola VM, y un terminal sin campo vm: enruta a mt5, así que los despliegues existentes no se enteran. La misma versión añadió tests de contrato para los handlers que mueven dinero: manejan las rutas reales de Flask con el SDK de MT5 falseado en la costura m() y comprueban la petición exacta que llegaría a order_send, un BUY a mercado al precio ask, un SELL al bid, un cierre parcial mandando solo el volumen pedido, una modificación de solo sl preservando el tp existente. Cada camino de fallo comprueba además que order_send no se llamó nunca, porque un handler que revienta después de enviar ya ha operado. El CI ahora sí corre todo eso en pushes y PRs; pipeline.yml antes solo se disparaba con tags v*, lo que significaba que la suite de tests/ no había corrido nunca en CI.
v4.11 cerró el último hueco entre los dos catálogos de MCP: las herramientas tipadas por terminal y las unificadas exponen ya parámetros de rango from/to que coinciden, para ticks y para rates de TA, con tests de esquema y de paridad que cubren ambas.
La Limpieza
La VM de Windows pasa por una limpieza agresiva en el primer arranque. Desactivar todas las animaciones, la transparencia, el fondo de pantalla. Matar SysMain, el audio, la cola de impresión, la búsqueda, la telemetría y otros cincuenta servicios inútiles. Quitar Windows Defender entero, no desactivarlo, quitarlo. Tomar posesión de los directorios de Defender y borrar los binarios. Reventar toda la porquería que te invade la privacidad: ID de publicidad, historial de actividad, datos de diagnóstico, todos los permisos de capacidades. Desactivar cada tarea programada de espionaje de Microsoft. Poner la prioridad de procesador en primer plano, reducir los tiempos de kill, desactivar los timestamps de NTFS.
El resultado es un Windows 11 que arranca rápido, se queda bajo en reposo y no llama a casa de Microsoft cada 30 segundos. Justo el sistema operativo necesario para correr MT5 y la API de Python.
Ahora Cada Binario Vendorizado Tiene que Declararse
Un repo que arranca una VM de Windows y le arranca Defender acumula ejecutables. No muchos, pero los que tiene son justo los que menos te apetecería dar por buenos, y llevaban ahí en el árbol sin que nadie los mirara.
v4.12.0 añadió make verify-binaries. Todo ejecutable versionado tiene que estar declarado en assets/binaries.lock.json con su sha256, su origen upstream y el estado de su firma. Un binario sin declarar tumba el build. Uno cambiado tumba el build. Una firma degradada tumba el build. Corre lo primero dentro de make test, así que el CI lo impone en cada PR en vez de cuando alguien se acuerde.
La gracia de una barrera así no es la regla, es lo que la regla encuentra en cuanto la enciendes. Aquí documentó al instante scripts/defender-remover/PowerRun.exe:
"path": "scripts/defender-remover/PowerRun.exe",
"product": "PowerRun",
"vendor": "Sordum Software",
"signature": "malformed",
"note": "REPACKED, NOT PRISTINE..."Llegó vendorizado dentro del kit de defender-remover en vez de venir directo de Sordum. Su directorio de certificados no es un WIN_CERTIFICATE bien formado, una longitud declarada de 776284822, revisión 0xc496, tipo 14951, frente a un 0x200 y un 2 exigidos, y su hash no cuadra con ninguna release de Sordum upstream, así que la firma no se puede verificar contra nada.
Para que quede claro qué significa y qué no: no se sabe que sea malicioso. Un montón de herramientas reempaquetadas tienen esta pinta. Lo que cambió es que ya no es silencioso, el estado está escrito, en el repo, al lado del fichero, y el CI falla si alguna vez se mueve. Un binario sin explicar del que sabes es un riesgo distinto de un binario sin explicar del que no sabes.
La misma versión llevó la suite de tests unitarios de 244 tests a 379. Todo lo que antes solo corría contra un terminal vivo corre ahora en CI, manejando la app Flask real contra un SDK guionizado, así que mt5client, el monitor y el cliente de Go recibieron su primera cobertura.
Logs
Todo desemboca en data/metatrader5/logs/ en el host:
- install.log: el progreso de la instalación de MT5
- start-mt5.log: el log de la secuencia de arranque
- pip.log: la instalación de paquetes de Python
- api-<broker>-<account>.log: los logs de API por terminal
- full.log: la manguera concatenada con todo lo anterior más las entradas del Visor de eventos de Windows sacadas desde dentro de la VM. Este es el que pilla los kills por OOM y los procesos que Defender mata en silencio, que no salen en ningún otro sitio.
Un sidecar aparte de rotación de logs corre junto a la VM y lo rota todo a diario con 7 días de retención. Se acabaron los ficheros de log de 4 GB comiéndote el disco tras una semana con el stack levantado. Cuando algo se rompe, full.log es el primer sitio donde mirar, cronológico, un solo fichero, todo en un solo flujo.
Sidecar de Tailscale
Exponer públicamente una API de trading es pedir que te atraquen. La mayoría de la gente quiere esto accesible desde su portátil y desde ningún otro sitio. Así que hay un sidecar de Tailscale integrado que se une a tu tailnet y sirve la API en un nombre MagicDNS pelado:
http://mt5-httpapi/roboforex/main/account
http://mt5-httpapi/roboforex/main/symbols/EURUSD/rates?count=100
http://mt5-httpapi/ftmo/challenge1/positionsPones la clave de autenticación en config.yaml, descomentas el bloque tailscale en docker-compose.yml, make up. Funciona con Tailscale de serie y con Headscale autoalojado (para este último pones login_server). HTTP pelado por diseño, la capa de wireguard ya cifra todo dentro del tailnet, y los nombres MagicDNS pelados de todas formas no tienen certificados TLS que cuadren.
El sidecar corre en su propio netns (modo bridge, no la red del host), así que obtiene su propia identidad en el tailnet. Tus ACL se limitan al nodo del sidecar, el Tailscale del host (si tiene uno) se queda completamente fuera del asunto, y cualquier tráfico con destino al tailnet desde el sidecar sale por su propia interfaz tailscale0, no por la del host. Tailscale Serve escucha en el puerto 80 dentro del netns y pasa al sidecar de nginx siempre encendido por la red interna de docker. El estado persiste en .data/tailscale/state/, así que make down / make up reaprovecha el login existente, la clave de autenticación solo se consume en el primer login.
El token de API (si lo pusiste) se sigue aplicando encima, Tailscale controla la alcanzabilidad de red, el token bearer controla el acceso a la aplicación. Defensa por capas.
Cloudflare Tunnel (Cuando de Verdad Necesitas Público)
Si de verdad necesitas esto accesible desde internet abierto, digamos para engancharlo a un bot alojado o a un frontend en Vercel, hay una opción de Cloudflare Tunnel. cloudflared llama hacia fuera al borde de Cloudflare y pasa al sidecar de nginx siempre encendido. Un túnel, un nombre de host, cada terminal alcanzable detrás de /<broker>/<account>/:
https://mt5-api.yourdomain.com/roboforex/main/account
https://mt5-api.yourdomain.com/ftmo/challenge1/positionsSin puertos abiertos en el firewall. Sin perforar NAT. Sin certificados que gestionar, Cloudflare termina el TLS en el borde gratis bajo su Universal SSL. Montaje: instalas cloudflared en el host una vez, creas un túnel, enrutas un nombre de host a él, sueltas las credenciales en .data/cloudflared/, descomentas el bloque cloudflared en compose, make up.
Trata el nombre de host público como hostil y pon siempre api_token en config.yaml cuando uses esto. Cloudflare controla la alcanzabilidad pública; el token bearer controla la aplicación. Si te saltas el token aquí, cualquiera que encuentre el nombre de host te puede vaciar la cuenta.
En Resumen
MetaTrader 5 en Docker con una API REST. VM de Windows de verdad vía KVM, no Wine. Varios brókeres y varias cuentas corriendo a la vez sobre recursos mínimos. Datos de mercado completos, gestión de órdenes, seguimiento de posiciones, historial de operaciones, TA en servidor vía el sidecar de wickworks, y un pipeline completo de Strategy Tester sobre HTTP, todo detrás de JSON pelado. Más un cliente tipado de Go, un skill de agente de IA y herramientas MCP tipadas, por terminal o unificadas sobre todos los terminales a la vez, para que dejes a un LLM operar contra la API sin darle la documentación con cuchara.
Sin MQL5. Sin escritorio de Windows. Sin librerías de MT5 en el lado del cliente. Solo curl y andando.
Cógelo aquí: github.com/psyb0t/mt5-httpapi
Con licencia WTFPL, porque el trading debería exigir un descargo de responsabilidad, no una licencia de software.