Quant500 Academy

1. Scheduling execution: periodic tasks, queues and workflows

Level 9: Orchestration and the calendar

You already have a strategy that generates correct orders when you launch it yourself, by hand, with the laptop open. Production begins with a boring question: who launches it when you are not there. The answer is not an infinite loop with a sleep inside; that is the fastest way to discover three weeks later that the process has been dead for nine days and nobody noticed. Triggering work is a problem with known solutions and choosing badly costs money.

The three ways of triggering work

When the simplest one is enough

The practical rule: choose the smallest mechanism that survives your worst day, not your normal day. If your strategy decides once a day, has four steps and you can redo the whole session by hand in five minutes, a scheduled task is more than enough. Adding an orchestrator there does not give you robustness: it gives you one more service to maintain, update and watch, and one that also falls over.

You need a queue when work arrives pushed and in bursts: broker messages, webhooks, market events you cannot afford to lose while you restart. You need an orchestrator when the number of tasks and dependencies no longer fits in your head, when you want to recompute three late days without touching the rest, or when several people depend on knowing whether today's data step finished properly.

And a warning that saves grief: every mechanism you add is one more component that can fall over and that has a log of its own nobody reads. A badly watched orchestrator is no more robust than a cron job: it is the same thing with more failure surface and the false feeling that somebody is looking.

Overlap: two copies on the same state

Here is the classic failure. You schedule a task every minute. It takes ninety seconds. From the second minute onwards you permanently have two live copies: the one that has just started and the previous one, which has not finished yet. Both read the same position, both compute the same signal and both send the same order. You end up with double the size you wanted, and with two processes writing the same state file in an order nobody has decided.

The dangerous part is that the number of copies need not stop at two. If those two copies compete for the same connection, the same rate limit or the same disk, each takes longer, and the overlap grows. A system that was already tight becomes a saturated system without any component having failed.

A lock that does not lie

The solution is a mutual exclusion lock: before working, the task tries to acquire it; if it cannot, it withdraws without doing anything and records the fact. A usable lock has three properties: it is acquired atomically, you know who holds it, and it is released on its own if the owner dies. A marker file created and deleted by hand fails on exactly the third: if the process dies abruptly, the file stays and blocks the system forever.

import fcntl
import os
from contextlib import contextmanager

class YaEstaCorriendo(Exception):
    pass

@contextmanager
def cerrojo(ruta):
    """File lock. The operating system releases it if the process dies."""
    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()

The usage is deliberately boring: if the lock is taken, this minute's run is not queued or postponed, it simply does not happen.

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")

If instead of cron you use a scheduler inside the process, ask for the same thing through configuration: a single simultaneous instance per task, coalescing late runs into one and discarding those that arrive too late.

from apscheduler.schedulers.blocking import BlockingScheduler

sched = BlockingScheduler(timezone="UTC")
sched.add_job(
    ciclo_de_trading,
    "cron", minute="*",
    max_instances=1,       # never two copies of this task
    coalesce=True,         # several late ones run only once
    misfire_grace_time=20  # if it arrives more than 20 s late, it is discarded
)

Worked scenario

A worked scenario, with example figures, not a public case. A task running every minute walks a universe of sixty names and queries each one's price with a call that takes 1.4 seconds on average: 84 seconds of network alone, plus the computation. With the machine idle it fitted inside the minute; with the market busy, it did not. For twenty-six minutes there were two overlapping copies and duplicate orders were sent on fourteen of the sixty names.

The two fixes are independent and both are needed: the lock, which prevents the damage, and batching the sixty queries into a single bulk request, which brings the cycle down to about four seconds and removes the cause. The lock without the second one leaves you a system that does not break but that skips half its runs. This is 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.