Quant500 Academy

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:

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 coid

Si 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.status

Escenario 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.

El resto de Quant500

Lo que estás aprendiendo, aplicado al índice todos los días:

La cartera del modelo, cada semana en tu correo

Cada sábado, su semana en un minuto: cómo fue, lo mejor y lo peor, y qué directivos compraron con su dinero. Y el día que el modelo se rehace, qué entra y qué sale. Es una simulación, no un consejo. Como mucho un correo a la semana, sin publicidad y sin cuenta; te das de baja con un clic. Lo hace una persona: Pol Gracia.

Cartera modelo con resultados simulados, no una recomendación personalizada. Tu correo se usa solo para esto: privacidad.

Detrás de la Academia y del resto de Quant500 hay una sola persona: Pol Gracia Serrahima.