Quant500 Academy

2. Kill switches and hard limits

Level 11: When it fails

A hard limit is a condition the system checks before sending each order and which, if not met, prevents the send. It is not a warning, it is not a line in the log, it is not one more strategy parameter. It is a closed door. The strategy proposes and the checker disposes, and its default answer is no.

These limits protect you from yourself, not from the market

It is worth dispelling a very widespread misunderstanding early. A daily loss limit does not protect you from a bad run: a bad run is within what your strategy does, you saw it in the backtest and you will see it again. What a hard limit really stops is the other thing: the loop that sends the same order three hundred times, the size computed with a divisor that came out zero, the reference price that was yesterday's, the five o'clock deployment with a sign flipped.

Put another way: hard limits are damage control against software and operational errors, not a market risk management technique. That they also cut off some unpleasant market tail is a pleasant side effect. If you design them with the market in mind you will set them far too loose for exactly what they were supposed to catch. From that follow three properties that are not negotiable:

The five minimum limits

class LimiteDuro(Exception):
    pass

class Limites:
    def __init__(self, estado, max_ordenes=40, max_peso=0.10,
                 max_bruto=0.95, max_desvio_precio=0.05, max_pct_volumen=0.02):
        self.estado = estado
        self.max_ordenes = max_ordenes
        self.max_peso = max_peso
        self.max_bruto = max_bruto
        self.max_desvio_precio = max_desvio_precio
        self.max_pct_volumen = max_pct_volumen

    def comprobar(self, orden, cartera, referencia):
        if self.estado.freno_activo():
            raise LimiteDuro('freno activado: %s' % self.estado.motivo())
        if self.estado.ordenes_hoy() >= self.max_ordenes:
            raise LimiteDuro('tope de ordenes de la sesion')
        peso = abs(orden.cantidad * referencia.precio) / cartera.patrimonio
        if peso > self.max_peso:
            raise LimiteDuro('peso %.3f sobre el maximo' % peso)
        if cartera.bruto_tras(orden) > self.max_bruto:
            raise LimiteDuro('exposicion bruta fuera de rango')
        if orden.cantidad > self.max_pct_volumen * referencia.volumen_medio:
            raise LimiteDuro('tamano fuera de lo razonable')
        if orden.limite is not None:
            desvio = abs(orden.limite / referencia.precio - 1)
            if desvio > self.max_desvio_precio:
                raise LimiteDuro('precio limite absurdo: %.3f' % desvio)
        return True

Each check returns a readable reason, and that is not cosmetic: the day it fires, the difference between fixing it in two minutes and spending the morning guessing is that piece of text. And the first line consults the brake, because the question that precedes all others is whether the system has permission to trade.

Where the counter lives

The error that recurs in every home-made system: the order counter and the brake live in an attribute of the object, so you restart the process and they go back to zero. And the scenario in which you most need the limit is precisely a loop of failure and restart: it starts, sends orders, falls over, the supervisor brings it back, it starts, it sends orders. With the counter in memory, a cap of forty orders per session allows forty per restart, and a cap is not a cap if it can be restarted away.

def registrar_envio(conn, hoy):
    with conn:
        conn.execute(
            "INSERT INTO contador(sesion, n) VALUES(?, 1) "
            "ON CONFLICT(sesion) DO UPDATE SET n = n + 1", (hoy,))

def revisar_perdida_diaria(conn, patrimonio, apertura, max_perdida=0.03):
    caida = patrimonio / apertura - 1
    if caida < -max_perdida:
        activar_freno(conn, 'perdida diaria %.2f por ciento' % (caida * 100))
        return False
    return True

The counter and the brake are stored with the session's date, in the same database where you keep the state, and the brake is only released by hand. None of this clearing itself the next day: if it fired, somebody has to look at why before trading again. The opening equity is recorded when the session opens and is not recomputed, because if you recompute it after a fall the limit moves with it and stops being a limit.

The specific numbers in this module are teaching examples to illustrate the mechanism; this is education, not financial advice.

Real case: Mizuho Securities, December 2005

A Mizuho Securities trader meant to sell one share of the company J-Com at 610,000 yen and typed the order exactly the other way round: 610,000 shares at one yen. That quantity far exceeded the total number of the company's existing shares. The order entered the market and the loss to Mizuho was of the order of tens of billions of yen, that is, hundreds of millions of dollars.

What matters is not the typing error, which is unavoidable and will happen again tomorrow on another desk. What matters is everything that failed to stop it: nothing compared the quantity against the name's usual volume, nothing compared the price against the prevailing quote, and the warning that did appear could be dismissed with a keystroke. Either of this module's two sanity checks, even with very generous thresholds, would have blocked that order before it went out.

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.