3. Dependencias entre tareas y fallos parciales
Nivel 9: Orquestación y calendario
Una estrategia en producción no es una tarea, son cinco encadenadas: descargar datos, calcular señales, dimensionar posiciones, ejecutar y conciliar. Cuando las cinco funcionan, la cadena es invisible. El día que la primera devuelve la mitad de los datos y la segunda calcula igual, la cadena decide tu dinero. Este módulo va de eso: de qué hace el sistema cuando un eslabón falla a mitad. No hablamos aquí de reintentar una llamada concreta con espera creciente, sino de qué le pasa a la tarea entera y a todas las que dependen de ella.
La cadena y sus dependencias reales
- Descargar datos depende de que el proveedor responda y de que el universo esté completo.
- Calcular señales depende de que la descarga haya terminado bien, no de que haya terminado.
- Dimensionar depende de las señales y del capital y las posiciones reales, no de las que tú creías tener.
- Ejecutar depende del dimensionado y del calendario del módulo anterior.
- Conciliar depende de la ejecución, pero es la única que debe correr también cuando la ejecución falló, porque es la que averigua qué llegó a pasar de verdad.
Esa penúltima frase es la clave del módulo: terminar no es lo mismo que terminar bien, y el sistema tiene que distinguirlo sin ambigüedad.
Declarar la dependencia, no confiar en el orden
Poner las llamadas una detrás de otra en un archivo no declara nada: si la primera lanza una excepción que alguien capturó por comodidad, la segunda corre igual. Declarar una dependencia significa que una tarea solo arranca si el estado registrado de sus predecesoras es correcto, y que ese estado sobrevive al proceso.
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 # no se intenta siquiera
continue
try:
funciones[t.nombre]()
estado[t.nombre] = OK
except Exception:
estado[t.nombre] = FALLO # y arrastra a sus dependientes
return estadoLo importante de este bucle no es lo que hace, sino lo que no hace: no hay ninguna ruta por la que ejecutar arranque si dimensionar quedó en FALLO. La omisión se propaga sola por el grafo y queda registrada, que es distinto de no haber ocurrido.
Conviene además fijar el número de intentos y el punto en el que la tarea se rinde. Algo que se reintenta indefinidamente no es resistente: es una tarea que nunca termina y que impide que el flujo llegue a la conciliación, que es justo lo que necesitas leer cuando algo va mal. Tres intentos y un fallo declarado valen más que un bucle sin final.
Datos incompletos: la señal que parece buena
El fallo más caro no es el ruidoso, es el silencioso. El proveedor responde con código 200 y devuelve 380 de los 500 valores del universo. La señal se calcula, sale un número, y ese número está calculado sobre un universo distinto del que definiste. Si tu estrategia ordena por percentil, faltar 120 valores mueve todos los cortes; si compara contra la media del universo, la media es otra.
Por eso la comprobación de completitud no es una advertencia en el registro: es un fallo duro de la tarea de datos.
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 dfRellenar los huecos con el precio de ayer parece prudente y es lo contrario: convierte un fallo visible en una señal falsa que nadie va a revisar.
Reintentar o saltarse: no son intercambiables
Reintentar una tarea significa: creo que el fallo es transitorio y que volver a intentarlo puede dar un resultado correcto. Saltarse una tarea significa: acepto seguir sin ella. Son decisiones distintas y solo la primera es casi siempre segura.
- Se reintenta lo transitorio y lo idempotente: una descarga que agotó su tiempo de espera, una consulta de posiciones que devolvió error de servidor.
- Se omite lo opcional y solo lo opcional: enviar el informe diario por correo, actualizar un panel, exportar un fichero de análisis.
- Nunca se omite un eslabón del que dependen decisiones: si dimensionar falló, ejecutar no se salta el paso, se cancela.
Hay una asimetría que conviene tener presente: casi todos los errores de este nivel llevan a operar de más, no de menos. Los datos incompletos generan órdenes, y la señal calculada sobre un universo recortado también. Un sistema que se apaga ante la duda se equivoca en la dirección barata.
La regla que resume el módulo entero: ante la duda, no operar. Un día sin operar cuesta un día de rendimiento esperado; un día operando con datos incompletos puede costar la cartera entera, y además ensucia el registro con el que mañana intentarás entender qué pasó.
def clasificar(exc):
if isinstance(exc, (TimeoutError, ConnectionError)):
return "reintentable"
if isinstance(exc, DatosIncompletos):
return "fatal" # nunca se reintenta a ciegas ni se ignora
return "fatal" # lo desconocido se trata como grave, no como leveEscenario trabajado
Escenario trabajado, con cifras de ejemplo. Un universo de 500 valores; el proveedor entrega 380 y responde con éxito. Sin validación, la estrategia calcula sus deciles sobre 380 nombres: los cortes se desplazan y 41 valores cambian de decil, de los cuales 12 pasan de no estar en cartera a estar en el decil comprador. El sistema ejecuta doce entradas que la estrategia real nunca habría pedido, y la conciliación del día siguiente las encuentra en cartera sin explicación.
Con la validación, la tarea de datos queda en FALLO, señales y ejecución quedan en OMITIDA, la conciliación corre igual y confirma que la cartera sigue como ayer. El coste es un día sin ajustar. Formación, no asesoramiento financiero.