Workflow
Scheduling Trading Algorithms — Cron, Docker, and Serverless
A backtest runs once. A systematic strategy runs every trading day, often multiple times per day, on a schedule that does not depend on a human remembering to start it. The scheduling layer is not glamorous, but a strategy that fails to run is worse than no strategy — at least a strategy that never ran has not missed trades you expected, left positions open in a falling market, or created a gap in the performance record you are trying to evaluate.
This is the part of systematic trading that most tutorials skip. They show you how to write the signal logic and then stop. Getting that logic to run reliably at 9:35am every weekday — on a machine that is not your laptop, without anyone monitoring it — is a distinct engineering problem. Choosing the right approach depends on what failure modes you can tolerate, how much infrastructure you want to manage, and what the strategy itself requires.
The Three Approaches
| Approach | Best for | Failure mode | Infrastructure |
|---|---|---|---|
| Cron | Prototyping, learning | Single machine, no resilience | Laptop or VPS |
| Docker | Production daily strategies | Container management overhead | VPS or cloud VM |
| Serverless | Event-driven, low-frequency | Cold start latency, time limits | Cloud function |
None of these is universally correct. The right choice depends on the strategy's requirements, and those requirements usually shift as the strategy moves from prototype to production. Most strategies start with cron, graduate to Docker when reliability becomes important, and occasionally move to serverless if the strategy is truly event-driven rather than clock-driven.
Approach 1 — Cron Jobs
Cron is the simplest approach. A cron job runs a command on a schedule without external dependencies. Appropriate for a strategy that runs once per day and can tolerate "did not run because the laptop was closed." That tolerance is the key qualifier — cron requires the host machine to be running and healthy at the scheduled execution time.
Create the strategy script at strategies/ma_crossover.py:
#!/usr/bin/env python3
import sys
import logging
from datetime import datetime
logging.basicConfig(
filename="/var/log/trading/ma_crossover.log",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s"
)
def main():
logging.info("Strategy run started")
# ... strategy logic here
logging.info("Strategy run completed")
if __name__ == "__main__":
main()
Add to crontab with crontab -e:
# Run at 9:35am ET Monday-Friday
35 9 * * 1-5 /home/user/trading/venv/bin/python /home/user/trading/strategies/ma_crossover.py
Use the full path to the virtual environment's Python — not the system Python. The system Python will not have your strategy's dependencies installed. The difference between /usr/bin/python3 and /home/user/trading/venv/bin/python determines whether your strategy's imports succeed or fail silently without any visible error. Cron's failure notification is minimal by default — if the command exits with an error and no mail transfer agent is configured, the failure is invisible.
Key limitations of cron: it does not retry on failure, it does not alert when a scheduled run is missed, and it stops working entirely if the machine reboots without cron persisting. On a personal laptop, cron jobs do not run while the lid is closed or the machine is sleeping. If the strategy was scheduled for 9:35am ET and the laptop was sleeping, the job did not run. Nothing in the default cron setup notifies you that it was missed.
For learning and prototyping on a development machine, these limitations are acceptable trade-offs. For capital that matters, they are not.
Approach 2 — Docker on a VPS
Docker provides the resilience and isolation that cron lacks. The strategy runs in a container that restarts on failure, is isolated from the host environment, and can be deployed to any VPS (Virtual Private Server) without dependency conflicts. The container image is the complete environment — the strategy runs identically regardless of what else is installed on the host, regardless of the host's Python version, and regardless of which system packages are present or absent.
Start with a Dockerfile that captures the full environment:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY strategies/ strategies/
COPY .env .
CMD ["python", "strategies/ma_crossover.py"]
Use docker-compose.yml to manage the container lifecycle declaratively:
version: "3.8"
services:
strategy:
build: .
restart: unless-stopped
environment:
- ALPACA_API_KEY=${ALPACA_API_KEY}
- ALPACA_SECRET_KEY=${ALPACA_SECRET_KEY}
volumes:
- ./logs:/app/logs
restart: unless-stopped means Docker restarts the container automatically if it exits with an error code. This covers the most common failure mode: the strategy crashes due to an unhandled exception and would otherwise stay down until someone manually restarts it. Combined with a Python scheduler inside the container, you get resilience against crashes without external orchestration.
For scheduled execution within the container, use the schedule library rather than system cron:
import schedule
import time
import logging
def run_strategy():
logging.info("Scheduled run triggered")
# ... strategy logic
schedule.every().monday.at("09:35").do(run_strategy)
schedule.every().tuesday.at("09:35").do(run_strategy)
schedule.every().wednesday.at("09:35").do(run_strategy)
schedule.every().thursday.at("09:35").do(run_strategy)
schedule.every().friday.at("09:35").do(run_strategy)
while True:
schedule.run_pending()
time.sleep(60)
The while True loop runs continuously inside the container. The schedule library fires the strategy function at the configured times. If the strategy function raises an exception, the exception surfaces in the container logs and the loop continues — the next scheduled run still executes. This is more resilient than running the strategy as the container's main process, because a crash in the main process exits the container, whereas an exception in the scheduler loop does not.
Deploy to a VPS:
# Build locally and transfer to VPS
docker build -t ma-crossover .
docker save ma-crossover | ssh user@your-vps "docker load"
ssh user@your-vps "docker-compose up -d"
Monitor from the VPS:
docker logs ma-crossover --follow
docker stats ma-crossover
A $6/month DigitalOcean Droplet (1 vCPU, 1GB RAM) handles a single daily strategy with room to spare. Multiple strategies can run in separate containers on the same VPS, each with its own environment and isolated logs. The container approach also makes deployments deterministic — updating a strategy means rebuilding the image with the new code and restarting the container, not hoping that a manual file edit on the server did not leave something in an inconsistent state.
One frequently overlooked detail for trading containers: timezone. The python:3.11-slim base image defaults to UTC. If your schedule calls use "09:35" and the container clock is UTC, that fires at 9:35am UTC — not 9:35am Eastern Time. Set the timezone explicitly in the Dockerfile:
ENV TZ=America/New_York
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
Alternatively, schedule in UTC and handle the conversion in code. Either approach is correct; the failure mode is deploying without choosing one and discovering the strategy fires at 2:35pm ET on the first live day.
Approach 3 — Serverless Functions
Serverless is appropriate for strategies that run infrequently — once per day or less — or respond to external events rather than clock time. The function only runs when triggered, meaning no persistent process to maintain, minimal cost for low-frequency execution, and no container lifecycle to manage.
DigitalOcean Functions (and equivalents on AWS Lambda, Google Cloud Functions) support Python natively:
# packages/trading/daily-signal/index.py
def main(args):
symbol = args.get("symbol", "SPY")
# run signal evaluation
# return result as JSON
return {"status": "ok", "symbol": symbol, "action": "hold"}
Trigger the function via HTTP or on a cron schedule configured in the cloud provider's dashboard. DigitalOcean Functions support native cron-triggered invocations through their triggers interface — no external scheduler is required.
Serverless limitations are important to understand before choosing this approach. Execution time limits are typically 5–30 seconds depending on the provider and function configuration. A strategy that streams real-time data, maintains state across multiple evaluations, or runs computation that takes longer than the time limit does not fit the serverless model.
Cold start latency — the delay on the first invocation after an idle period — can range from 1 to 5 seconds on cold functions. For a strategy triggered at 9:35am after a 16-hour idle period since yesterday's close, the cold start adds latency to the first run of the day. On most cloud providers that latency is 1–3 seconds — acceptable for daily strategies executing market orders, problematic for latency-sensitive intraday execution where seconds matter.
Serverless functions also have no persistent state between invocations. If your strategy needs to track position state, pending order state, or rolling metrics across multiple runs, that state must live in an external store — a database, object storage bucket, or cache — and be read at the start of each invocation. This adds latency and complexity. For truly stateless, event-driven strategies, serverless is elegant. For stateful daily strategies, it adds overhead that Docker on a VPS avoids.
Choosing the Right Approach
- Prototyping and learning: cron on your development machine
- Production daily strategy on one or two symbols: Docker on a $6/month VPS
- Production multi-strategy portfolio: Docker Compose with one container per strategy, shared VPS
- Low-frequency event-triggered signal that runs in under 30 seconds and requires no persistent state: serverless function with HTTP or cron trigger
- Real-time intraday strategy requiring a persistent WebSocket connection: long-running process in Docker, never serverless
The most common mistake is running a production strategy under cron on a server that is not actively monitored. Cron provides zero visibility into whether scheduled runs succeeded or failed — only whether the command was executed. A strategy that silently stops running because of a Python import error on a Tuesday morning will not alert you. You discover the gap in performance data when you review returns at the end of the week.
The Critical Non-Negotiable: Logging
Regardless of approach, every strategy run must write structured logs. The minimum entries for any run are: run start timestamp, signals evaluated for each symbol with the computed values, orders placed or not placed with the reason, errors encountered, and run end timestamp.
Without logs, debugging a failure at 9:36am on a trading day means guessing at what happened. With logs, it means reading:
import logging
import json
from datetime import datetime
def log_trade_decision(symbol: str, signal: float, action: str, reason: str) -> None:
entry = {
"timestamp": datetime.utcnow().isoformat(),
"symbol": symbol,
"signal": signal,
"action": action,
"reason": reason,
}
logging.info(json.dumps(entry))
Logging as structured JSON rather than free-form text makes logs machine-queryable. When you need every trade decision for SPY in the past 30 days, grep '"symbol": "SPY"' ma_crossover.log | jq . produces the answer immediately. Free-form text logging requires pattern matching that breaks whenever log message phrasing changes.
Write logs to a file — not only to stdout. Container logs and serverless invocation logs are often not persisted long-term by the platform's default configuration. A log file written to mounted storage survives container restarts and serverless cold starts, and is queryable after the fact without a paid logging service.
A secondary pattern worth implementing for production: heartbeat logging. At the start of every scheduled run, write a log entry regardless of whether the strategy takes any action. This entry — even if it only says "run started, no signal triggered" — proves the run executed. Without it, a log file full of only trade decisions cannot distinguish between "no trades occurred" and "the strategy did not run."
def log_heartbeat(strategy_name: str, symbols: list) -> None:
entry = {
"timestamp": datetime.utcnow().isoformat(),
"event": "run_start",
"strategy": strategy_name,
"symbols": symbols,
}
logging.info(json.dumps(entry))
Call this at the top of main() before any signal computation. The presence of a heartbeat entry in the log at 9:35am confirms the run happened.
The Oyamori Approach
Oyamori manages the scheduling and execution infrastructure. Strategies run on Oyamori's execution layer — no cron configuration, no Docker container to maintain, no VPS to provision. The platform handles reliability, retry logic, missed-run detection, and structured logging as baseline infrastructure, not as something each trader builds independently for each strategy.
The trade-off is scope: strategy logic executes within Oyamori's environment rather than arbitrary custom code. For the catalog edges, that is the correct trade-off. For fully custom algorithmic research requiring arbitrary Python and full infrastructure control, the approaches above remain the path — and the patterns in this guide apply directly to that use case.