2. Idempotencia: reintentar sin mandar la orden dos veces
Nivel 8: Estado y ejecución fiable
Este es el módulo más importante del nivel. Mandas una orden de compra de 500 títulos, la petición sale, y la conexión se corta antes de que llegue la respuesta. Tu programa recibe una excepción. Y ahora contesta: ¿llegó la orden al bróker?
No lo sabes. No puedes saberlo con la información que tienes. Y esa ignorancia es la que arruina cuentas.
El fallo que no puedes distinguir desde tu lado
Cuando una llamada de red falla sin respuesta, hay tres mundos posibles y todos producen exactamente el mismo error en tu consola:
- La petición nunca llegó al bróker. La orden no existe. Hay que mandarla.
- La petición llegó y se procesó, pero la respuesta se perdió por el camino. La orden existe y quizá ya se ha ejecutado. Volver a mandarla duplica la posición.
- La petición llegó y el bróker la estaba procesando cuando se cortó. Puede acabar existiendo un segundo después.
El instinto es reintentar. El instinto está mal: reintentar a ciegas convierte el segundo mundo en 1.000 títulos comprados en lugar de 500, con el doble de riesgo y sin ninguna señal que lo respalde. Y el instinto contrario, no reintentar nunca, tampoco vale: en el primer mundo te quedas sin operar mientras el modelo cree que está dentro.
La solución: un identificador de cliente único por orden
Casi todas las APIs de bróker modernas aceptan que tú pongas el identificador de la orden, además del suyo. Se suele llamar client order id, y su contrato es el que necesitas: si mandas dos órdenes con el mismo identificador, la segunda se rechaza. El nombre exacto del campo y la ventana durante la cual se garantiza la unicidad cambian de un bróker a otro, así que léete su documentación antes de confiar en ello.
Con eso, reintentar deja de ser peligroso: la operación se vuelve idempotente, es decir, ejecutarla una vez o cinco produce el mismo efecto. El bróker se convierte en el guardián que impide el duplicado, que es exactamente donde tiene que estar el candado, porque es el único punto por el que pasan todas tus peticiones. Un candado que vive en tu proceso no sirve: si el proceso muere, muere con él.
Conviene saber qué llamadas necesitan esta protección y cuáles no. Consultar posiciones o precios es idempotente por naturaleza: preguntar dos veces no cambia nada. Cancelar también lo es casi siempre, porque cancelar algo ya cancelado devuelve un error inofensivo. Las peligrosas son exactamente dos: crear una orden y modificar una orden existente, porque las dos alteran tu exposición. Esas son las que jamás deben salir sin identificador propio.
Determinista, no aleatorio
Aquí viene el detalle que casi todo el mundo falla. Si generas el identificador con uuid4 justo antes de mandar, tienes un valor único, sí, pero si el proceso muere entre generarlo y persistirlo, ese valor se ha perdido para siempre: no puedes preguntar por él ni regenerarlo. Un identificador determinista se puede reconstruir a partir de la decisión que lo originó.
import hashlib
def client_order_id(estrategia, simbolo, lado, cantidad, barra_iso):
# OJO: nada de time.time() ni de aleatorios. Solo datos de la DECISION.
semilla = "|".join([estrategia, simbolo, lado, str(cantidad), barra_iso])
resumen = hashlib.sha256(semilla.encode("utf-8")).hexdigest()[:20]
return "q500-" + resumen
# La misma decision produce SIEMPRE el mismo identificador
print(client_order_id("media_movil", "AAPL", "buy", 500, "2026-08-10T15:30:00Z"))El prefijo no es decorativo: al arrancar te permitirá distinguir tus órdenes de las que haya metido otro sistema o una persona a mano. Y la semilla no lleva la hora actual, sino la hora de la barra: si no fuera así, cada reintento generaría un identificador distinto y el candado no cerraría nunca.
El patrón completo: intención, envío, confirmación
El orden de las tres escrituras es lo que hace que el sistema sea recuperable.
def enviar_orden(con, api, estrategia, simbolo, lado, cantidad, barra_iso):
coid = client_order_id(estrategia, simbolo, lado, cantidad, barra_iso)
# 1. La INTENCION va a disco ANTES de tocar la red, y se confirma.
con.execute(
"INSERT OR IGNORE INTO ordenes "
"(client_order_id, simbolo, lado, cantidad, estado, barra, creada_en) "
"VALUES (?, ?, ?, ?, 'CREADA', ?, datetime('now'))",
(coid, simbolo, lado, cantidad, barra_iso))
con.commit()
# 2. Solo entonces se manda, con su identificador dentro.
respuesta = api.submit_order(symbol=simbolo, side=lado, qty=cantidad,
type="market", time_in_force="day",
client_order_id=coid, timeout=10)
# 3. Y se registra lo que ha contestado.
con.execute("UPDATE ordenes SET estado='ENVIADA', broker_id=? "
"WHERE client_order_id=?", (respuesta.id, coid))
con.commit()
return coidSi el proceso muere entre el paso 1 y el 2, en disco hay una fila CREADA y en el mercado no hay nada. Si muere entre el 2 y el 3, hay una fila CREADA y una orden viva que tu base de datos no reconoce como enviada. Las dos situaciones son recuperables porque en las dos existe la fila.
Al recuperarte, pregunta; no reintentes
La recuperación no consiste en reenviar lo dudoso, sino en resolver la duda. Al arrancar, recorre todas las filas que no estén en un estado final y, para cada una, pregunta al bróker por su identificador de cliente. Si existe, copias su estado real; si no existe, y solo entonces, puedes reenviarla con el mismo identificador de siempre.
def resolver_dudosa(con, api, coid):
try:
orden = api.get_order_by_client_order_id(coid)
except NotFound:
return "no_existe" # seguro reenviar, con el MISMO identificador
con.execute("UPDATE ordenes SET estado=?, broker_id=? WHERE client_order_id=?",
(orden.status.upper(), orden.id, coid))
con.commit()
return orden.statusEscenario trabajado: la doble compra de las 15:31
Escenario construido para el curso. Un robot manda una compra de 500 títulos a 92 euros. La red se corta, salta el reintento y manda otra idéntica. Las dos llegan. Resultado: 1.000 títulos, 92.000 euros de exposición donde el gestor de riesgo había autorizado 46.000, y un descubierto que el bróker cierra a mercado con el hueco de apertura en contra. La misma secuencia con un client order id determinista termina distinto: la segunda petición se rechaza por identificador duplicado, el sistema consulta ese identificador, ve una orden ejecutada de 500 y sigue con la posición que tocaba.