Quant500 Academy

1. Testing a trading system is harder than it looks

Level 12: Testing, deploying and taking the step

You arrive here with a system that already exists: a deterministic structure with no hidden effects, idempotent state governed by a state machine, a scheduler that respects the calendar, logging, metrics, alerts and reconciliation, and kill switches for the known failure modes. None of that gets explained again. The question that opens this last level is a different and uncomfortable one: how do you prove all of that works before the market proves it to you with money on the table.

Why this is not tested the way a website is tested

A decent test needs three things: a reproducible input, a unit isolated from the world and a cheap failure. A trading system breaks all three. The market does not deliver the same data twice, so if your test downloads prices it is no longer a test, it is an experiment: when it goes red you will not know whether you have broken the code or whether the market has behaved differently. The piece you want to test talks to a broker that is not yours, one you cannot restart or ask to reject an order at 10:31 to see what your code does. And failure does not cost a support ticket, it costs a position.

From that come this module's four decisions, and none of them is optional: isolate the pure logic, replace the broker with a double you control, provoke the rare cases deliberately and compare each version with the previous one on the same data.

Pure logic is tested with frozen data

The part that decides does not read from disk, does not call an API and does not look at the clock. It takes data and state, returns proposed orders, and with the same input it always returns the same output. If your decision function checks the time internally, you do not have a test: you have a result with an expiry date. The data is written by hand into the repository, with numbers chosen to make the case obvious.

import pandas as pd
from estrategia import decidir

CONFIG = {"ventana": 3, "unidades": 10}

def indice(n):
    return pd.date_range("2024-03-01", periods=n, freq="B", tz="UTC")

def test_el_cruce_dispara_una_sola_compra():
    velas = pd.DataFrame({"cierre": [100.0, 101.0, 99.0, 103.0, 104.0]}, index=indice(5))
    ordenes = decidir(velas, posicion=0, config=CONFIG)
    assert [(o.lado, o.cantidad) for o in ordenes] == [("compra", 10)]

def test_sin_senal_no_manda_nada():
    velas = pd.DataFrame({"cierre": [100.0] * 5}, index=indice(5))
    assert decidir(velas, posicion=0, config=CONFIG) == []

Look at the second test. Almost everybody checks that the signal fires and forgets to check that it does not fire when it should not. In production the frequent case is the second one, and a spurious order costs exactly as much as a good one.

A fake broker that misbehaves on purpose

You do not test against the real broker: it is slow, it has rate limits and its rejections cannot be ordered to measure. You test against a double that implements the same interface and to which you dictate the script. Its value is not in returning "filled", but in returning what the real one returns on a bad Tuesday.

class BrokerFalso:
    def __init__(self, guion):
        self.guion = list(guion)
        self.enviadas = []

    def enviar(self, orden):
        self.enviadas.append(orden)
        if not self.guion:
            raise AssertionError("el sistema mando mas ordenes de las previstas")
        return self.guion.pop(0)

def test_la_ejecucion_parcial_no_infla_la_posicion():
    broker = BrokerFalso([
        {"estado": "parcial", "pedido": 10, "ejecutado": 4, "id": "A1"},
        {"estado": "rechazada", "motivo": "fuera de horario", "id": "A2"},
    ])
    motor = Motor(broker, posicion=0)
    motor.ciclo(senal="compra 10")
    assert motor.posicion == 4
    motor.ciclo(senal="compra 6")
    assert motor.posicion == 4
    assert len(broker.enviadas) == 2

That double is also the place where you check Level 8's idempotency: call the same cycle twice with the same intent identifier and verify that the broker received one order, not two.

The rare cases are the normal case

Comparing the new version with the previous one

The last family of tests does not check that the result is correct, but that it has not changed without you asking. You store the current version's output over a fixed stretch of data and every subsequent change is measured against it.

import json, pathlib

REFERENCIA = pathlib.Path("pruebas/referencia_2019_2021.json")

def test_las_decisiones_no_cambian_sin_avisar():
    salida = [o.como_dict() for o in reproducir("datos/tramo_2019_2021.parquet")]
    esperado = json.loads(REFERENCIA.read_text(encoding="utf-8"))
    assert salida == esperado, "cambian las decisiones: revisa antes de tocar la referencia"

When that test goes red there are two legitimate outcomes: either you have broken something, or you have changed the strategy on purpose and you update the reference in the same commit that explains it. Updating it because it was annoying is not one of them.

Worked scenario

This is not a historical case, it is an example put together with concrete figures. A system slices large orders into lots. A refactoring moves the lot-size rounding from one module to another. All 140 unit tests pass. The comparison test fails on 3 of 512 decisions in the reference stretch: a size of 97 units now gets sliced into 50 and 47 instead of 49 and 48. It looks cosmetic. It is not: the same rounding applied to closing the position left an orphan unit, and that unit showed up every few weeks as a discrepancy in the daily reconciliation, with its corresponding alert at seven in the morning. 0.6% of decisions different and not one unit test able to see it.

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.