1. Programar la ejecución: tareas periódicas, colas y flujos
Nivel 9: Orquestación y calendario
Ya tienes una estrategia que genera órdenes correctas cuando la lanzas tú, a mano, con el portátil abierto. Producción empieza con una pregunta aburrida: quién la lanza cuando tú no estás. La respuesta no es un bucle infinito con una espera dentro; esa es la forma más rápida de descubrir tres semanas después que el proceso lleva nueve días muerto y que nadie se ha enterado. Disparar trabajo es un problema con soluciones conocidas y elegir mal cuesta dinero.
Las tres formas de disparar trabajo
- Tarea periódica programada. El disparador es el reloj: cron, un temporizador de systemd o un planificador dentro del propio proceso. Dices "esto, a esta hora" y el sistema se encarga. Es la opción con menos piezas móviles y la única que sigue en pie cuando lo demás se ha caído.
- Cola de mensajes. El disparador es un evento. Alguien publica un mensaje (hay una vela nueva, el bróker ha confirmado una ejecución) y uno o varios consumidores lo recogen. El productor no espera al consumidor: si el consumidor está parado, el trabajo se acumula en la cola en lugar de perderse. Es la forma natural de absorber ráfagas y de repartir carga entre varias máquinas.
- Orquestador de flujos. También dispara por reloj, pero además conoce el grafo: qué tarea depende de cuál, en qué estado está cada una hoy, y te deja relanzar solo el trozo que falló sin repetir lo que ya salió bien.
Cuándo basta la más simple
La regla práctica: elige el mecanismo más pequeño que aguante tu peor día, no tu día normal. Si tu estrategia decide una vez al día, tiene cuatro pasos y puedes rehacer la jornada entera a mano en cinco minutos, una tarea programada te sobra. Añadir un orquestador ahí no te da robustez: te da un servicio más que mantener, actualizar y vigilar, y que también se cae.
Necesitas una cola cuando el trabajo llega empujado y a ráfagas: mensajes del bróker, webhooks, eventos de mercado que no puedes permitirte perder mientras reinicias. Necesitas un orquestador cuando el número de tareas y dependencias ya no te cabe en la cabeza, cuando quieres recalcular tres días atrasados sin tocar el resto, o cuando varias personas dependen de saber si el paso de datos de hoy terminó bien.
Y una advertencia que ahorra disgustos: cada mecanismo que añades es un componente más que puede caerse y que tiene su propio registro que nadie lee. Un orquestador mal vigilado no es más robusto que una tarea de cron: es lo mismo con más superficie de fallo y con la sensación falsa de que alguien mira.
El solapamiento: dos copias sobre el mismo estado
Aquí está el fallo clásico. Programas una tarea cada minuto. Tarda noventa segundos. A partir del segundo minuto tienes, de forma permanente, dos copias vivas: la que acaba de arrancar y la anterior, que aún no ha terminado. Las dos leen la misma posición, las dos calculan la misma señal y las dos mandan la misma orden. Acabas con el doble de tamaño del que querías, y con dos procesos escribiendo el mismo fichero de estado en un orden que nadie ha decidido.
Lo peligroso es que el número de copias no tiene por qué quedarse en dos. Si esas dos copias compiten por la misma conexión, por el mismo límite de peticiones o por el mismo disco, cada una tarda más, y el solapamiento crece. Un sistema que iba justo se convierte en un sistema saturado sin que ningún componente haya fallado.
Un cerrojo que no miente
La solución es un cerrojo de exclusión mutua: antes de trabajar, la tarea intenta adquirirlo; si no puede, se retira sin hacer nada y lo registra. Un cerrojo servible tiene tres propiedades: se adquiere de forma atómica, se sabe quién lo tiene y se libera solo si el dueño muere. Un fichero testigo creado y borrado a mano falla justo en lo tercero: si el proceso se muere de golpe, el fichero se queda y bloquea el sistema para siempre.
import fcntl
import os
from contextlib import contextmanager
class YaEstaCorriendo(Exception):
pass
@contextmanager
def cerrojo(ruta):
"""Cerrojo de fichero. El sistema operativo lo suelta si el proceso muere."""
f = open(ruta, "a+")
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
f.close()
raise YaEstaCorriendo(ruta)
try:
f.write(str(os.getpid()))
f.flush()
yield
finally:
fcntl.flock(f, fcntl.LOCK_UN)
f.close()El uso es deliberadamente aburrido: si el cerrojo está cogido, la ejecución de este minuto no se encola ni se pospone, simplemente no ocurre.
import logging
def main():
try:
with cerrojo("/var/run/estrategia.lock"):
ciclo_de_trading()
except YaEstaCorriendo:
logging.warning("ciclo anterior todavia vivo: esta ejecucion se descarta")Si en lugar de cron usas un planificador dentro del proceso, pide lo mismo por configuración: una sola instancia simultánea por tarea, fusionar las ejecuciones atrasadas en una y descartar las que lleguen con demasiado retraso.
from apscheduler.schedulers.blocking import BlockingScheduler
sched = BlockingScheduler(timezone="UTC")
sched.add_job(
ciclo_de_trading,
"cron", minute="*",
max_instances=1, # nunca dos copias de esta tarea
coalesce=True, # varias atrasadas se ejecutan una sola vez
misfire_grace_time=20 # si llega con mas de 20 s de retraso, se descarta
)Escenario trabajado
Escenario trabajado, con cifras de ejemplo, no un caso público. Una tarea cada minuto recorre un universo de sesenta valores y consulta el precio de cada uno con una llamada que tarda de media 1,4 segundos: 84 segundos solo de red, más el cálculo. Con la máquina descansada cabía en el minuto; con el mercado agitado, no. Durante veintiséis minutos hubo dos copias solapadas y se enviaron órdenes duplicadas en catorce de los sesenta valores.
Las dos correcciones son independientes y hacen falta las dos: el cerrojo, que impide el daño, y agrupar las sesenta consultas en una sola petición por lotes, que baja el ciclo a unos cuatro segundos y elimina la causa. El cerrojo sin lo segundo te deja un sistema que no se rompe pero que se salta la mitad de sus ejecuciones. Esto es formación, no asesoramiento financiero.