Quant500 Academy

3. Dependencies between tasks and partial failures

Level 9: Orchestration and the calendar

A strategy in production is not one task, it is five chained together: download data, compute signals, size positions, execute and reconcile. When all five work, the chain is invisible. The day the first returns half the data and the second computes anyway, the chain decides your money. This module is about that: about what the system does when a link fails halfway. We are not talking here about retrying a particular call with a growing wait, but about what happens to the whole task and to everything that depends on it.

The chain and its real dependencies

That penultimate sentence is the key to the module: finishing is not the same as finishing well, and the system has to tell them apart unambiguously.

Declare the dependency, do not trust the ordering

Putting the calls one after another in a file declares nothing: if the first throws an exception somebody caught for convenience, the second runs anyway. Declaring a dependency means a task only starts if the recorded state of its predecessors is correct, and that this state outlives the process.

from dataclasses import dataclass

OK, FALLO, OMITIDA = "OK", "FALLO", "OMITIDA"

@dataclass(frozen=True)
class Tarea:
    nombre: str
    depende_de: tuple = ()

def ejecutar_flujo(tareas, funciones):
    estado = {}
    for t in tareas:
        if any(estado.get(d) != OK for d in t.depende_de):
            estado[t.nombre] = OMITIDA          # not even attempted
            continue
        try:
            funciones[t.nombre]()
            estado[t.nombre] = OK
        except Exception:
            estado[t.nombre] = FALLO            # and it drags its dependants down
    return estado

What matters about this loop is not what it does, but what it does not do: there is no path by which execution starts if sizing ended in FALLO. The omission propagates through the graph on its own and is recorded, which is different from not having happened.

It is also worth fixing the number of attempts and the point at which the task gives up. Something retried indefinitely is not resilient: it is a task that never finishes and that stops the workflow reaching reconciliation, which is exactly what you need to read when something goes wrong. Three attempts and a declared failure are worth more than an endless loop.

Incomplete data: the signal that looks fine

The most expensive failure is not the noisy one, it is the silent one. The provider responds with code 200 and returns 380 of the universe's 500 names. The signal is computed, a number comes out, and that number is computed over a different universe from the one you defined. If your strategy ranks by percentile, 120 missing names move every cut-off; if it compares against the universe mean, the mean is a different one.

That is why the completeness check is not a warning in the log: it is a hard failure of the data task.

class DatosIncompletos(Exception):
    pass

def validar_descarga(df, universo_esperado, sesion):
    faltan = set(universo_esperado) - set(df["simbolo"].unique())
    if faltan:
        raise DatosIncompletos("faltan " + str(len(faltan)) + " valores del universo")
    viejos = df[df["fecha"] < sesion]
    if not viejos.empty:
        raise DatosIncompletos("hay filas con fecha anterior a la sesion de hoy")
    return df

Filling the gaps with yesterday's price looks prudent and is the opposite: it turns a visible failure into a false signal nobody is going to review.

Retry or skip: they are not interchangeable

Retrying a task means: I believe the failure is transient and that trying again may give a correct result. Skipping a task means: I accept carrying on without it. They are different decisions and only the first is almost always safe.

There is an asymmetry worth bearing in mind: nearly every error at this level leads to trading too much, not too little. Incomplete data generates orders, and so does a signal computed over a truncated universe. A system that shuts down when in doubt errs in the cheap direction.

The rule that sums up the whole module: when in doubt, do not trade. A day without trading costs a day of expected return; a day trading on incomplete data can cost the whole portfolio, and it also pollutes the log you will use tomorrow to try to understand what happened.

def clasificar(exc):
    if isinstance(exc, (TimeoutError, ConnectionError)):
        return "reintentable"
    if isinstance(exc, DatosIncompletos):
        return "fatal"          # never blindly retried, never ignored
    return "fatal"              # the unknown is treated as serious, not as minor

Worked scenario

A worked scenario, with example figures. A universe of 500 names; the provider delivers 380 and responds with success. With no validation, the strategy computes its deciles over 380 names: the cut-offs shift and 41 names change decile, of which 12 go from being out of the portfolio to being in the buying decile. The system executes twelve entries the real strategy would never have asked for, and the next day's reconciliation finds them in the portfolio with no explanation.

With the validation, the data task ends in FALLO, signals and execution end in OMITIDA, reconciliation runs anyway and confirms the portfolio is unchanged from yesterday. The cost is one day without rebalancing. Education, not financial advice.

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.