2. Idempotency: retrying without sending the order twice
Level 8: State and reliable execution
This is the most important module in the level. You send a buy order for 500 units, the request goes out, and the connection drops before the reply arrives. Your program gets an exception. Now answer this: did the order reach the broker?
You do not know. You cannot know with the information you have. And that ignorance is what wrecks accounts.
The failure you cannot tell apart from your side
When a network call fails with no reply, there are three possible worlds and all of them produce exactly the same error in your console:
- The request never reached the broker. The order does not exist. It has to be sent.
- The request arrived and was processed, but the reply was lost on the way back. The order exists and may already have been filled. Sending it again doubles the position.
- The request arrived and the broker was processing it when the line dropped. It may end up existing a second later.
The instinct is to retry. The instinct is wrong: retrying blind turns the second world into 1,000 units bought instead of 500, with twice the risk and no signal backing it. And the opposite instinct, never retrying, is no good either: in the first world you end up not trading at all while the model believes it is in.
The solution: a unique client identifier per order
Almost every modern broker API lets you set the order's identifier, alongside its own. It is usually called the client order id, and its contract is exactly what you need: if you send two orders with the same identifier, the second is rejected. The exact field name and the window during which uniqueness is guaranteed vary from broker to broker, so read their documentation before relying on it.
With that, retrying stops being dangerous: the operation becomes idempotent, that is, running it once or five times produces the same effect. The broker becomes the guard that prevents the duplicate, which is exactly where the lock has to be, because it is the single point every one of your requests passes through. A lock that lives inside your process is no use: if the process dies, it dies with it.
It is worth knowing which calls need this protection and which do not. Querying positions or prices is idempotent by nature: asking twice changes nothing. Canceling almost always is too, because canceling something already canceled returns a harmless error. The dangerous ones are exactly two: creating an order and modifying an existing order, because both alter your exposure. Those are the ones that must never go out without an identifier of their own.
Deterministic, not random
Here comes the detail almost everybody gets wrong. If you generate the identifier with uuid4 just before sending, you do have a unique value, but if the process dies between generating it and persisting it, that value is lost forever: you cannot ask about it or regenerate it. A deterministic identifier can be reconstructed from the decision that produced it.
import hashlib
def client_order_id(estrategia, simbolo, lado, cantidad, barra_iso):
# CAREFUL: no time.time(), no randomness. Only data from the DECISION.
semilla = "|".join([estrategia, simbolo, lado, str(cantidad), barra_iso])
resumen = hashlib.sha256(semilla.encode("utf-8")).hexdigest()[:20]
return "q500-" + resumen
# The same decision ALWAYS produces the same identifier
print(client_order_id("media_movil", "AAPL", "buy", 500, "2026-08-10T15:30:00Z"))The prefix is not decorative: on startup it will let you tell your orders apart from any placed by another system or by a person by hand. And the seed does not carry the current time, but the time of the bar: were it otherwise, every retry would generate a different identifier and the lock would never close.
The full pattern: intent, send, confirmation
The order of the three writes is what makes the system recoverable.
def enviar_orden(con, api, estrategia, simbolo, lado, cantidad, barra_iso):
coid = client_order_id(estrategia, simbolo, lado, cantidad, barra_iso)
# 1. The INTENT goes to disk BEFORE touching the network, and is committed.
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. Only then is it sent, with its identifier inside.
respuesta = api.submit_order(symbol=simbolo, side=lado, qty=cantidad,
type="market", time_in_force="day",
client_order_id=coid, timeout=10)
# 3. And what it replied is recorded.
con.execute("UPDATE ordenes SET estado='ENVIADA', broker_id=? "
"WHERE client_order_id=?", (respuesta.id, coid))
con.commit()
return coidIf the process dies between step 1 and step 2, there is a CREADA row on disk and nothing in the market. If it dies between 2 and 3, there is a CREADA row and a live order your database does not recognize as sent. Both situations are recoverable because in both the row exists.
When you recover, ask; do not retry
Recovery does not consist of resending what is doubtful, but of resolving the doubt. On startup, walk every row that is not in a final state and, for each one, ask the broker about its client identifier. If it exists, you copy its real status; if it does not exist, and only then, you may resend it with the same identifier as always.
def resolver_dudosa(con, api, coid):
try:
orden = api.get_order_by_client_order_id(coid)
except NotFound:
return "no_existe" # safe to resend, with the SAME identifier
con.execute("UPDATE ordenes SET estado=?, broker_id=? WHERE client_order_id=?",
(orden.status.upper(), orden.id, coid))
con.commit()
return orden.statusWorked scenario: the double buy at 15:31
A scenario built for the course. A bot sends a buy for 500 units at 92 euros. The network drops, the retry fires and sends an identical one. Both arrive. Result: 1,000 units, 92,000 euros of exposure where the risk manager had authorized 46,000, and an overdraft the broker closes at market with the opening gap against it. The same sequence with a deterministic client order id ends differently: the second request is rejected for a duplicate identifier, the system queries that identifier, sees a filled order for 500 and carries on with the position it was supposed to have.