4. The order as a state machine
Level 8: State and reliable execution
An order is not an event, it is a process with a life of its own. Treating it as a boolean ("sent, yes or no") is the mistake that produces phantom positions. Modeling it as a state machine forces you to write down in advance everything that can happen to it, and to make the code say "no" when something impossible occurs instead of accepting it in silence.
The states and their transitions
Seven states are enough for the vast majority of systems:
- CREADA. The intent is in your database and has not yet gone out on the network.
- ENVIADA. The request went out; you do not yet know whether the broker accepted it.
- ACEPTADA. The broker recognizes it and it lives in the book.
- PARCIAL. Part of the quantity has been filled. The rest is still live.
- EJECUTADA, CANCELADA, RECHAZADA. Terminal states: there is no way out of these.
The advantage of writing the transitions in a table, rather than scattering them across conditionals all over the code, is that the table reads at a glance and can be reviewed in five minutes. When somebody has to add a new state, they will see exactly where it can be reached from and where it can go.
That terminal states are terminal is not a triviality, it is a defense. A delayed event trying to move an EJECUTADA order back to PARCIAL must bounce off an exception, because it means something arrived out of order and applying it would corrupt your accounting.
TRANSICIONES = {
"CREADA": {"ENVIADA", "RECHAZADA"},
"ENVIADA": {"ACEPTADA", "RECHAZADA", "PARCIAL", "EJECUTADA"},
"ACEPTADA": {"PARCIAL", "EJECUTADA", "CANCELADA", "RECHAZADA"},
"PARCIAL": {"PARCIAL", "EJECUTADA", "CANCELADA"},
"EJECUTADA": set(), "CANCELADA": set(), "RECHAZADA": set(),
}
class TransicionIlegal(Exception):
pass
def transitar(actual, nuevo):
if nuevo not in TRANSICIONES[actual]:
raise TransicionIlegal(actual + " -> " + nuevo)
return nuevoPartial fills change the arithmetic
You ask for 500 units and get filled on 180. Your position is no longer what the model believes, and the order is still live for the remaining 320. Two rules that prevent 90% of accounting errors:
- The filled quantity is assigned, not added. Almost all brokers send the running total in each event, not the increment. If you accumulate by adding, you count the same shares several times.
- Every live order has a pending quantity, and that quantity is what you have to check before deciding anything new about that symbol.
And now the case that ruins your afternoon: you cancel a partially filled order. The cancellation only removes the pending part; what has already been filled is yours forever. If your code treats the cancellation as "this order did not happen" and sets the position to zero, you have just created an invisible position of 180 units. Canceling never undoes: it only stops.
def aplicar_evento(fila, evento):
# filled_qty arrives CUMULATIVE: assign it, never add it.
if evento.filled_qty < fila["ejecutada"]:
raise ValueError("evento atrasado, se descarta")
fila["ejecutada"] = evento.filled_qty
fila["pendiente"] = fila["cantidad"] - evento.filled_qty
if fila["pendiente"] == 0:
destino = "EJECUTADA"
elif evento.estado == "canceled":
destino = "CANCELADA" # we keep whatever was already filled
elif evento.filled_qty > 0:
destino = "PARCIAL"
else:
destino = "ACEPTADA"
if destino != fila["estado"]: # repeating the same event must break nothing
fila["estado"] = transitar(fila["estado"], destino)
return filaRepeated, delayed and lost events
Fill notifications arrive over a channel that is not reliable: it can repeat a message, deliver messages out of order, or stop emitting without closing the connection. That is why the function above does two things that look like paranoia and are not. It discards any event whose cumulative quantity is smaller than the one already recorded, because it can only be a delayed message. And because it assigns rather than adds, processing the same event twice is harmless: it is the same idea of idempotency from module two, now applied to data coming in instead of orders going out.
What neither of those defenses covers is the event that never arrives. Against that there is only one thing: not relying on the notification channel alone, and querying over REST the status of any order that has gone too long without moving.
Orphan orders at startup
An orphan order is one that exists on one side and not on the other. At startup you will meet three families, and each has its own answer:
- In your database as CREADA or ENVIADA, and the broker does not know it. Since the identifier is deterministic, you ask about it; if it really does not exist, you can resend it, provided the signal is still valid.
- Live at the broker with no row in your database. Look at the identifier's prefix: if it is yours, adopt it by creating the missing row with the status the broker reports. If it is not, do not touch it: quarantine it and raise an alert. Canceling somebody else's orders is an excellent way to break their system.
- In a non-terminal state for hours. Suspect a lost event, not a slow order. Query its real status over REST before taking any decision.
Startup rule: no new signal is executed until every order in a non-terminal state has been resolved. A bot that starts trading with doubtful orders open is betting money that its cache was right, and you already know who is in charge.
Worked scenario: the 17:20 cancellation
A scenario built for the course. The system has a sell for 500 units that has been in the book for half an hour. At 17:20 it decides to cancel it and replace it with a more aggressive one. By that point 180 had already been filled. The code marks it CANCELADA and, failing to distinguish states, leaves the symbol's position at 500 long, when in reality 320 remain. The replacement order is computed on 500 and sells 500: 180 units too many, a short position of 180 that no strategy asked for and nobody is watching, because the system believes it is flat. With the state machine above, the cancellation preserves the filled quantity, the position drops to 320 and the replacement order goes out for 320.