API del EA (devs avanzados)
Contrato HTTP entre el Expert Advisor y el backend. Para developers que quieran auditar o extender el EA.
Principios de diseño
- Versionado en el cuerpo. Todos los payloads JSON incluyen
"version": <int>. Un EAv=1y un backendv=2deben poder seguir hablando si el backend soporta ambos. - Autenticación por headers. Nunca en query params (aparecerían en logs de proxies).
- Entrega única. El backend garantiza que cada señal se entrega a
un solo poll (UPDATE atómico sobre
consumed_at IS NULL). - El EA no telemetriza fuera del dominio PineLink. Las cuentas de prop firms pueden detectar pings a terceros y marcar la cuenta como "bot externo".
- Endpoints públicos del EA usan service_role en BD (bypass RLS con validación manual de license_key + ea_secret).
Versionado
- Versión actual del contrato:
1 - El EA envía
X-EA-Contract-Version: 1en cada request. - El backend responde
X-Backend-Contract-Version: 1. - Cuando se introduzca
v=2, el backend soportará simultáneamente1y2durante al menos 60 días con headerDeprecation: <fecha>en las respuestas dev=1.
Autenticación
Todos los endpoints /api/ea/* requieren estos headers:
| Header | Ejemplo | Descripción |
|--------|---------|-------------|
| X-License-Key | mt5_ab12cd34 | ID público de la licencia. También es el path del webhook de TradingView. |
| X-EA-Secret | s_9f3a... | Secreto compartido. Se genera al crear la licencia y se rota desde el portal. |
| X-EA-Contract-Version | 1 | Versión del contrato que el EA entiende. |
El backend resuelve (license_key, ea_secret) → user_id en la tabla
licenses con la constraint is_active = true. Si alguna credencial
falla: 401 Unauthorized.
El endpoint /api/webhook/[licenseId] (TradingView) NO usa estos
headers — solo valida licenseId contra licenses.license_key. Por
eso la URL del webhook es secreta.
Endpoints
POST /api/ea/auth
Primera llamada del EA al arrancar. Valida credenciales, registra la
cuenta MT5 (vía account_number + broker_name) y devuelve la config
inicial.
Request:
{
"version": 1,
"account_number": 1234567,
"broker_name": "FundedNext",
"server_name": "FundedNext-MT5",
"account_balance": 100000.00
}
Response 200:
{
"version": 1,
"user_id": "uuid",
"license_id": "uuid",
"mt5_account_id": "uuid",
"ea_config": {
"risk_mode": "percent_balance",
"risk_value": 1.0,
"default_sl_mode": "pips",
"default_tp_mode": "pips",
"max_positions": 5,
"allow_pyramiding": false,
"close_on_reverse": true,
"shadow_sl_tp": false,
"polling_interval_seconds": 2,
"signal_ttl_seconds": 60
},
"target_config": {
"daily_profit_target": 1000.0,
"profit_target_unit": "usd",
"profit_target_action": "block_only",
"daily_loss_limit": 4500.0,
"loss_limit_unit": "usd",
"loss_limit_action": "close_positions",
"max_drawdown": 9500.0,
"max_drawdown_unit": "usd",
"max_drawdown_action": "close_and_stop_ea",
"timezone": "America/New_York"
},
"is_halted": false,
"halt_reason": null,
"realized_pnl_today": 0,
"trading_day": "2026-05-10"
}
Response 401: invalid_credentials — credenciales inválidas o licencia desactivada.
Response 426: upgrade_required — el EA es una versión antigua
(pre-v1.2.0) que no soporta multi-account. Bajar la última versión.
GET /api/ea/poll
El EA llama cada polling_interval_seconds (default 2s). Devuelve las
señales pendientes y las marca como consumidas atómicamente.
Request: query param opcional max=10 (default 10, max 50).
Response 200:
{
"version": 1,
"signals": [
{
"id": "uuid",
"received_at": "2026-05-10T12:34:56Z",
"parsed": {
"version": 1,
"action": "buy",
"symbol_generic": "EURUSD",
"symbol_broker": "EURUSD.m",
"order_type": "market",
"price_offset": null,
"sl_mode": "pips",
"sl_value": 15,
"tp_mode": "pips",
"tp_value": 30,
"risk_mode": "percent_balance",
"risk_value": 1.0,
"close_partial_pct": null,
"ticket": null,
"tag": null,
"comment": null,
"warnings": [],
"halt_reason": null
}
}
]
}
Semántica atómica del poll: el backend ejecuta:
UPDATE signals SET consumed_at = NOW()
WHERE license_id = $1 AND status = 'queued' AND consumed_at IS NULL
RETURNING *
LIMIT $max
FOR UPDATE SKIP LOCKED;
Si el EA recibe una señal pero el ack nunca llega (crash, timeout),
la señal queda en status='queued', consumed_at IS NOT NULL. Un cron
de "re-queue" del backend la vuelve a poner disponible después de N
minutos.
POST /api/ea/ack
Confirma que el EA recibió y procesó (o falló en procesar) una señal.
Request:
{
"version": 1,
"signal_id": "uuid",
"status": "executed",
"error": null
}
status ∈ executed, failed. Si failed, error contiene un
código del catálogo (ver Errores comunes del EA).
Response 200: { "ok": true }.
POST /api/ea/report
Reporta una operación real en MT5 (apertura, cierre, modificación).
El backend guarda en trades y recalcula daily_account_states.realized_pnl
para esa cuenta MT5 concretamente.
Request:
{
"version": 1,
"signal_id": "uuid-or-null",
"mt5_ticket": 123456789,
"magic_number": 999888,
"account_number": 1234567,
"action": "buy",
"symbol": "EURUSD.m",
"volume": 0.10,
"entry_price": 1.08234,
"sl_price": 1.08184,
"tp_price": 1.08284,
"close_price": null,
"pnl": null,
"status": "open",
"opened_at": "2026-05-10T12:34:57Z",
"closed_at": null,
"trading_day": "2026-05-10"
}
action ∈ buy, sell, close, close_partial, modify.
status ∈ open, closed, failed.
Cuando la operación se cierra, el EA envía un segundo report con
status="closed", close_price, pnl, closed_at.
Response 200: { "ok": true, "trade_id": "uuid" }.
GET /api/ea/config
Devuelve la configuración vigente y el estado de halt para esta cuenta MT5. El EA lo consulta cada 30s para detectar cambios remotos (el usuario editó targets desde el portal).
Response 200: mismo formato que auth pero sin user_id/license_id/mt5_account_id
(ya los conoce el EA).
POST /api/ea/heartbeat
Señal de vida. Cada N segundos (default 30s, configurable). Actualiza
licenses.last_heartbeat_at.
Request:
{
"version": 1,
"open_positions": 2,
"account_balance": 10500.50,
"account_equity": 10485.20,
"account_currency": "USD"
}
Response 200: { "ok": true }.
Webhook de TradingView
POST /api/webhook/[licenseId] — endpoint público que recibe alertas
de TradingView.
No requiere headers de auth — el path lleva la license_key que
identifica la cuenta. Por eso la URL es secreta.
Body: texto plano con la sintaxis del parser. Ver Sintaxis de señales.
Response 200 (caso normal):
{
"received": true,
"signal_id": "uuid",
"status": "queued",
"block_reason": null,
"received_at": "2026-05-10T12:34:56Z",
"halt_info": null,
"parsed": {
"action": "buy",
"symbol_generic": "EURUSD",
"symbol_broker": "EURUSD.m",
"warnings": []
}
}
Response 200 (señal bloqueada por halt):
{
"received": true,
"signal_id": "uuid",
"status": "blocked",
"block_reason": "daily_target_hit",
"received_at": "2026-05-10T12:34:56Z",
"halt_info": { "halt_reason": "loss_limit" },
"parsed": { ... }
}
Response 400: parse_error con message específico.
Response 401: invalid_license.
Response 413: payload_too_large (>10KB).
Response 429: rate_limited con header Retry-After.
Rate limiting
| Endpoint | Límite |
|----------|--------|
| POST /api/webhook/[licenseId] | 60/min y 1000/hora por license_id |
| POST /api/ea/auth | 20/min por IP |
| GET /api/ea/poll | 60/min |
| POST /api/ea/ack | sin límite duro (pero usar con sentido común) |
| POST /api/ea/report | 120/min |
| POST /api/ea/heartbeat | 10/min |
Al exceder: 429 con header Retry-After: <segundos>.
Retry e idempotencia
- El EA debe reintentar
ackyreportcon backoff exponencial (1s, 2s, 4s, 8s, máx 30s) ante errores 5xx o timeouts. ackes idempotente por(signal_id)— si llega dos veces con distintostatus, gana el primero.reportes idempotente por(user_id, mt5_ticket)— el mismo ticket en el mismo estado no duplica filas.
Señales sintéticas del motor de halt
El backend puede encolar señales que NO provienen de TradingView,
marcadas con raw_body que empieza por SYSTEM:. La única hoy es
close_all:
{
"version": 1,
"action": "close_all",
"symbol_generic": null,
"symbol_broker": null,
"order_type": "market",
"warnings": [],
"halt_reason": "profit_target",
"tag": "halt_close_all",
"comment": "System-generated close_all after halt"
}
Cuando el EA procesa close_all, debe cerrar todas las posiciones
abiertas de la cuenta MT5. Es el side-effect del
*_action='close_positions' o 'close_and_stop_ea' configurado en
target_account_configs.
Orden de evaluación de targets
Cuando se reporta un trade cerrado, el backend evalúa los targets en este orden estricto:
profit_target— sirealized_pnl >= targetloss_limit— sirealized_pnl <= -limitmax_drawdown— sisum(realized_pnl_acumulado) <= -drawdown
Al violarse el primero, el motor marca halt_reason con ese motivo y
para. No reporta violaciones simultáneas — para auditoría de prop
firms, el halt_reason indica el motivo principal.
Correlación de logs
Tanto el backend como el EA deben imprimir license_id y, cuando
aplique, signal_id / trade_id en cada log.
EA (Print() estructurado, prefijo [PineLink]):
[PineLink] [poll] license_id=mt5_ab12cd34 received 2 signals
[PineLink] [execute] signal_id=abc123 symbol=EURUSD.m action=buy volume=0.1 result=OK ticket=12345
Backend (JSON estructurado):
{"level":"info","endpoint":"/api/ea/poll","license_id":"uuid","signals_returned":2}
Source canónico
Este documento es la versión "para humanos". El source canónico
versionado vive en EA_API_CONTRACT.md del repo. Cualquier discrepancia
entre los dos: gana el del repo.
Próximos pasos
- El flujo señal → trade — la versión "concepto" del mismo flujo.
- Sintaxis de señales — la gramática completa del parser.