Quant500 Academy

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:

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 nuevo

Partial 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:

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 fila

Repeated, 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:

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.

The rest of Quant500

What you are learning, applied to the index every day:

The model portfolio, weekly in your inbox

Every Saturday, its week in one minute: how it went, the best and the worst, and which insiders bought with their own money. Plus, the day the model is rebuilt, which stocks come in and which go out. A simulation, not advice. At most one email a week, no ads, no account; one-click unsubscribe. Made by one person: Pol Gracia.

A model portfolio with simulated results, not a personalized recommendation. Your email is used for this only: privacy.

Behind the Academy and the rest of Quant500 there is one person: Pol Gracia Serrahima.