Docs

Órdenes idempotentes en MT5: nunca dupliques una ejecución al reintentar

Por qué reintentar una orden de MetaTrader 5 puede abrir una segunda posición, y cómo lo evita el Idempotency-Key de fxapis: claves de 24 horas, repeticiones, conflictos de solicitudes en curso y de reutilización, el resultado desconocido y patrones de claves para señales, copiadores y colas.

Toda integración de trading termina enviando la misma orden dos veces. Una conexión móvil se cae después del toque, un balanceador de carga agota el tiempo de espera, una cola vuelve a entregar un trabajo, un usuario hace doble clic. En la mayoría de las APIs, una solicitud duplicada es una molestia. En una API de trading es una segunda posición: exposición real que nadie eligió.

Esta guía explica de dónde vienen los duplicados, cómo se comporta exactamente el encabezado Idempotency-Key en fxapis y cómo elegir las claves para que un reintento siempre sea seguro.

Por qué los reintentos duplican operaciones

Cuando tu código envía una orden, pueden pasar tres cosas: vuelve la respuesta, vuelve un error, o no vuelve nada. El tercer caso es el difícil. La solicitud pudo morir antes de llegar al broker, o el broker pudo ejecutarla y la respuesta se perdió en el camino de vuelta. Desde tu lado, ambos casos son indistinguibles.

MetaTrader 5 lo hace concreto. Su servidor de trading responde con códigos de retorno, y algunos de ellos —un timeout, por ejemplo— describen lo que pasó con la espera, no con la orden. Una orden que "agotó el tiempo de espera" bien puede estar abierta.

Sin una forma de preguntar "¿mi solicitud anterior ya hizo esto?", un cliente tiene dos malas opciones: reenviar y arriesgar una doble ejecución, o rendirse y arriesgar perder la operación. La clave de idempotencia es esa pregunta, hecha por el servidor en tu nombre.

Cómo funciona el Idempotency-Key

Envía cualquier string único, de hasta 200 caracteres, en el encabezado Idempotency-Key de una orden:

curl -sS -X POST "https://api.fxapis.com/v1/accounts/$ACCOUNT_ID/orders/market" \
  -H "Authorization: Bearer $FXAPIS_KEY" \
  -H "Idempotency-Key: open:signal_9931:member_4821" \
  -H "Content-Type: application/json" \
  -d '{ "symbol": "XAUUSD", "side": "buy", "volume": "0.05" }'

Se respeta en las tres llamadas que pueden crear o quitar exposición: órdenes de mercado, órdenes pendientes y cerrar una posición. La primera solicitud con una clave la reclama; todo lo que viene después lo decide ese reclamo:

EnvíasRecibes
Misma clave, mismo cuerpo, la primera solicitud terminóEl mismo estado y cuerpo, repetidos, con el encabezado idempotent-replay: true. No se envía nada al broker.
Misma clave, mismo cuerpo, la primera solicitud sigue en curso409 IDEMPOTENCY_IN_FLIGHT. Espera un momento y envíala de nuevo con la misma clave.
Misma clave, cuerpo o endpoint distinto409 IDEMPOTENCY_KEY_REUSED. No se coloca nada. Es un error en cómo se generan las claves.
Misma clave después de SEND_FAILEDUn intento nuevo de verdad. Está demostrado que la orden nunca llegó al broker, así que la clave se liberó.
Misma clave en un cierre, cuando la posición ya no está404 POSITION_NOT_FOUND. Un cierre busca la posición antes de comprobar la clave, así que un cierre que ya se hizo responde esto en lugar de repetir su 201: tómalo como hecho.

Las claves se recuerdan durante 24 horas y pertenecen a tu workspace; después, una clave se libera y una solicitud con ella se trata como nueva. Una clave usada en un endpoint no se puede usar en otro: abrir y cerrar necesitan claves distintas.

Qué se repite, exactamente

Lo que sea con lo que terminó la primera solicitud. Eso incluye los fallos:

  • Un 201 de ejecución se repite como el mismo 201.
  • Un 422 ORDER_REJECTED se repite como el mismo rechazo. Si el broker lo rechazó porque el precio se movió (retryable: true) y decides volver a operar, esa es una decisión nueva: usa una clave nueva.
  • Un 503 ORDER_UNRESOLVED se repite como el mismo 503. La clave sigue reclamada a propósito: este es exactamente el caso en el que una segunda orden debe ser imposible.

Una clave solo se libera cuando sabemos que nada llegó al broker: después de SEND_FAILED, y cuando la solicitud se rechazó antes de que existiera ninguna orden —por ejemplo, una cuenta que no pudo ponerse en línea a tiempo (ACCOUNT_NOT_READY). Reintentarlas con la misma clave coloca la orden normalmente.

El resultado desconocido

ORDER_UNRESOLVED (503) significa que todavía no sabemos si el broker ejecutó la orden. La orden se registra en estado unknown, y puede estar activa ahora mismo.

Nunca la reenvíes: ni con la misma clave (recibirías el mismo 503) y menos con una clave nueva (así es como ocurren las dobles ejecuciones). En su lugar:

  1. Toma details[0].orderId del error.
  2. Consulta GET /v1/orders/{id} cada pocos segundos.
  3. Confirmamos el resultado contra los propios registros del broker automáticamente. La orden pasa a filled, partially_filled o rejected, según lo que realmente ocurrió.
  4. Para confirmarlo de inmediato en lugar de esperar, llama a POST /v1/accounts/{id}/reconcile.

Si los registros del broker coinciden con más de una operación, la orden se deja en unknown en lugar de adivinar, y necesita a una persona. GET /v1/orders?state=unknown es la lista sobre la que vale la pena alertar. Los detalles están en Trading → Confirmar resultados.

Un bucle de reintentos que siempre es seguro

import time, uuid, requests

RETRY_SAME_KEY = {"SEND_FAILED", "IDEMPOTENCY_IN_FLIGHT", "ACCOUNT_NOT_READY", "NO_RUNTIME", "RATE_LIMITED"}

def place_once(session, url, body, key):
    for attempt in range(6):
        try:
            r = session.post(url, json=body, headers={"Idempotency-Key": key}, timeout=90)
        except requests.RequestException:
            time.sleep(min(2 ** attempt, 10))        # no answer seen: same key, ask again
            continue
        if r.status_code == 201:
            return r.json()["data"]
        error = r.json()["error"]
        if error["code"] == "ORDER_UNRESOLVED":
            return {"state": "unknown", "id": error["details"][0]["orderId"]}  # poll it; never resend
        if error["code"] in RETRY_SAME_KEY:
            time.sleep(int(r.headers.get("retry-after", 0)) or min(2 ** attempt, 10))
            continue
        raise RuntimeError(f"{error['code']}: {error['message']}")   # rejected, invalid, quota…
    raise RuntimeError("gave up; the same key is still safe to retry later")

La regla que codifica: la clave se elige una vez, antes del primer intento, y nunca cambia entre intentos. Generar un UUID nuevo dentro del bucle anula todo el mecanismo. Hay programas completos en siete lenguajes en las guías por lenguaje.

Elegir las claves

Una clave debe identificar la decisión de operar, no la solicitud HTTP. Dos solicitudes que llevan la misma decisión deben llevar la misma clave; dos decisiones distintas nunca deben compartir una.

Una acción del usuario → una clave creada cuando se toma la acción

Para un botón en tu app, crea la clave cuando la orden se crea en tu base de datos, guárdala en esa fila y envíala en cada intento. Si tu proceso se cae entre el envío y el registro de la respuesta, el proceso reiniciado encuentra la fila, envía la misma clave y recibe la respuesta original.

// When the user confirms — before any request is sent.
const intent = await db.orderIntents.insert({
  userId, accountId, symbol, side, volume,
  idempotencyKey: `intent:${crypto.randomUUID()}`,
});
// Later, and on every retry, from any worker:
await placeOnce(intent.accountId, intent, intent.idempotencyKey);

Una señal que toman muchos miembros → señal + miembro

open:signal_9931:member_4821

Un miembro que toca dos veces, su teléfono que reintenta y tu worker que reintenta se reducen a una sola orden. Otro miembro, u otra señal, recibe otra clave. Este es el patrón que usa Señales y click-to-trade.

Un copiador de operaciones → deal de la maestra + seguidora

copy:open:master_deal_5518823:follower_7f3a
copy:close:master_deal_5518901:follower_7f3a

El id del deal del broker de la cuenta maestra es único y estable, así que por muchas veces que tu poller vea el mismo deal, cada seguidora recibe una sola orden por él. Consulta Construye un copiador de operaciones de MT5.

Cierres y cierres parciales

Para un cierre completo, close:{accountId}:{ticket} es una buena clave. Para cierres parciales de la misma posición, agrega algo que distinga una decisión de la siguiente —un número de secuencia o el id de la regla que se activó—, porque un segundo cierre parcial con la misma clave y el mismo cuerpo se repetiría, no se ejecutaría, y uno con la misma clave pero otro volumen se rechazaría como IDEMPOTENCY_KEY_REUSED.

Un cierre repetido cuando la posición ya no está devuelve 404 POSITION_NOT_FOUND ("That position is already closed"). Tómalo como hecho, no como un error que reintentar.

Trabajos de colas

Las colas entregan al menos una vez. Pon la clave en el payload del trabajo cuando se encola —nunca la generes dentro del trabajo— para que un trabajo reentregado la reutilice.

Órdenes multicuenta

Las órdenes multicuenta también aceptan un Idempotency-Key. Se guarda con la propia orden: volver a enviar la misma clave devuelve la orden multicuenta que creó esa clave —200 con idempotent-replay: true— en lugar de crear otra. A diferencia de las órdenes individuales, el cuerpo no se compara: la misma clave con otro cuerpo igual devuelve la primera orden multicuenta, no un 409. Esto importa más que en ningún otro lugar, porque una orden multicuenta duplicada es una operación duplicada en todas las cuentas a la vez. Usa una clave por decisión, por ejemplo wave:signal_9931.

Lo que la idempotencia no cubre

  • Otras formas de entrar a la misma cuenta. Una clave elimina duplicados de las solicitudes a fxapis. Si alguien también opera la cuenta desde MetaTrader en el escritorio u otra plataforma, esas operaciones son suyas: aparecen en los deals con orderId: null.
  • Claves de más de 24 horas. Después de ese tiempo, una clave se libera y la misma clave inicia una orden nueva. No confíes en ella para eliminar el duplicado de un reintento de la orden de ayer; revisa primero GET /v1/orders.
  • Mover stops y cancelar. modify y cancel no aceptan el encabezado. Los niveles son precios absolutos, así que repetir un modify o vuelve a fijar los mismos niveles o el broker lo rechaza como sin cambios; ninguna de las dos cosas abre ni cierra nada.

Lista de comprobación

  • Cada orden de mercado, orden pendiente y cierre envía un Idempotency-Key.
  • La clave se crea una vez, se guarda con la intención y se reutiliza en cada reintento.
  • Las claves identifican decisiones: señal + miembro, deal de la maestra + seguidora, el id de una intención guardada.
  • Abrir, cerrar y cada cierre parcial tienen claves distintas.
  • ORDER_UNRESOLVED nunca se reenvía; la orden se consulta hasta que sale de unknown.
  • ORDER_REJECTED con retryable: true solo se reintenta como una decisión nueva, con una clave nueva.
  • Se monitorea GET /v1/orders?state=unknown.

Relacionado: Errores, Trading → Los tres fallos, y las guías de Python y Node.js para programas completos.

En esta página