Weeks 5 and 6 showed you the two engines. This week: how a new piece plugs into them, and how any of it runs at 7am without you.
You've now seen both engines. This week is the skills that apply to working inside them — and the part nobody teaches you in a class, because it only matters when code runs without a human watching.
Two halves. First, system design: how a new component plugs into an existing engine, where its boundaries sit, and which tradeoffs are different because the system is live. Second, running it: where the code executes, how it gets scheduled, and how it gets credentials without you pasting them into a file.
By Friday you should be able to:
You will almost never build a system from scratch here. You'll add a component to a system that already runs: a new fetcher, a new cleaner, a new strategy, a new risk check. The design question is never "how would I build this?" It's "where does this plug in, and what does it promise?"
A well-built system has already answered that question for you. It has an abstract base class per role, and adding a capability means implementing one — a new vendor, a new destination, a new strategy. Week 5 showed the shape: find the role your work belongs to, implement that interface, and register it in config. If you find yourself editing the controller, stop and check whether you've picked the wrong seam.
Whatever you add, it answers four questions:
| Question | Concretely |
|---|---|
| What triggers it? | A schedule, or a call from the controller that owns it. |
| What does it read? | Which tables and columns — and what it assumes is already true about them. |
| What does it produce? | A table, a column, a position. One owner, one writer. |
| What happens when it fails? | Retry, skip, or halt. Silence is not one of the options. |
Give every table exactly one component that writes it. Everything else reads. It's the cheapest design constraint you can adopt, and it pays for itself the first time a number looks wrong — because there is exactly one place it could have come from.
Two writers to one table is a bug you haven't hit yet. It stays invisible until the day both run at once.
An interface is the promise your component makes to everything downstream. It isn't the code — it's the shape of what crosses the line, and it's the part you can't change casually once something depends on it.
The biggest interface you'll meet here is a table:
producer ──writes──▶ a table ──reads──▶ consumer
The schema is the contract. Integrating through shared storage is common and sensible — it lets systems in different languages and different repositories evolve separately. It's also a database boundary, which behaves differently from an interface inside one codebase. There, a function signature is compiler-enforced: change it and the build breaks. Across a table, the agreement is the schema, and neither side's compiler can see the other.
So the guarantees are maintained deliberately:
The most expensive coupling isn't a function call — it's a shared assumption nobody wrote down. "Futures symbols arrive already rolled" is an interface between a setting in the pipeline's config and every strategy in the trade engine. It never appears in a signature, so the only way it stays true is that someone knows it and writes it down. When you add a component, the assumptions it makes about its inputs are part of what you're shipping — say them out loud.
General software engineering has opinions about all three. A live trading system pushes each one somewhere unusual — that's what makes this the QD-specific part of the job.
Not "fast" — fast enough for the horizon you trade. Our strategies hold positions for weeks. Nothing in our stack is latency-sensitive in the way a market-making desk would recognize, and pretending otherwise would be cargo-culting.
What actually matters is ordering: the data has to be in the table before the engine reads it. That's not a latency budget, it's a scheduling gap — and we buy it with hours of slack, not milliseconds.
The trap: optimizing latency nobody needs. Ask what the strategy's horizon is before you spend a week shaving time off a daily job. The honest answer — "this doesn't matter" — is worth more than the optimization.
The question isn't whether a vendor API times out — it will. The question is what your component does about it. Three options:
Which one is right depends on what the component feeds, and the split is worth thinking through rather than defaulting. An ingestion job can usually afford to skip one symbol and log it — the gap self-heals on the next run. A job that feeds trading decisions usually can't, because incomplete data there doesn't produce an error; it produces a confident wrong answer that everything downstream trusts.
The trap: a bare except: pass. It converts a loud failure into a silent one, and a silently stale input will keep feeding decisions for weeks before anyone notices. Note that a deliberate skip is still logged — skipping and hiding are different things.
Stale data doesn't throw. That's what makes it the dangerous one. A crashed job gets noticed; a job that succeeds while the table quietly stops updating gets noticed by nobody, and the engine happily trades on last week's number.
Nothing in the chain misbehaves: an incremental pipeline asks for everything after its latest row, a vendor returns nothing, the request succeeds, and zero rows is a valid answer. No error is raised anywhere, so freshness has to be asserted rather than assumed:
latest = data_access.get_latest_date_for(schema, table)
if latest is None or (today - latest) > MAX_STALENESS:
raise StaleDataError(f"{table}: newest row is {latest}, expected {today}")
The trap: assuming "the job ran" means "the data is current." Those are different claims, and only one of them is worth anything.
Your laptop is not the runtime. This sounds obvious and is the source of roughly half of all "but it worked for me" incidents.
Our stack runs on AWS EC2, in containers, 24/7. Code that runs on your machine has your environment variables, your Python version, your packages, your network access, your clock, and your filesystem. The server has none of those.
Three properties the runtime has that your laptop doesn't:
Where things run, what's installed, and who has access change over time and aren't worth memorizing from a training page. Ask your QD lead for current access and the deployment runbook. What's worth carrying is the mindset: the runtime is a different machine than yours, and it will punish every assumption you didn't check.
The canonical QD job: "pull yesterday's data every morning before we need it." Nobody types that command. It's scheduled.
We do this two different ways, and the difference is instructive.
Airflow is a scheduler. That's the one-sentence version, and it's most of what you need this week.
You describe your job in a Python file: what to run, how often, what to do if it fails. Airflow's scheduler reads that file, fires the job on schedule, records whether each run succeeded, retries what it's told to retry, and gives you a web UI showing green and red squares per day. That's the deal — you write the work, Airflow decides when it happens and remembers what happened.
Three words you'll see constantly:
PythonOperator: "call this function."We run one DAG per data pipeline. Each fires early in the morning, a few minutes apart, and does one thing: run that pipeline's Orchestrator against its config. A DAG per pipeline, a task per DAG.
Here's the shape of one, lightly simplified. It's shorter than you'd expect:
local_tz = pendulum.timezone("America/New_York")
default_args = {
"owner": "airflow",
"depends_on_past": False,
"email_on_failure": True,
"email_on_retry": False,
"retries": 1,
"retry_delay": timedelta(minutes=5),
}
def run_pipeline(**kwargs):
import asyncio # ← note: imports inside the function
from utils.dynamic_loader import load_config
from src.orchestrator import Orchestrator
config = load_config(CONFIG_PATH)
orchestrator = Orchestrator(config=config)
asyncio.run(orchestrator.run())
with DAG(
"data_pipeline_dag",
default_args=default_args,
schedule_interval="15 7 * * 1-5", # weekdays 7:15 AM ET
start_date=datetime(2024, 12, 1, tzinfo=local_tz),
catchup=False,
max_active_runs=1,
tags=["data_pipeline"],
) as dag:
PythonOperator(task_id="run_pipeline", python_callable=run_pipeline)
Six details in that file are load-bearing. Read them in order:
schedule_interval is cron syntax. Five fields — minute, hour, day, month, weekday. 15 7 * * 1-5 is "7:15, Monday through Friday." 1-5 and not * because markets are closed on weekends, and a run that finds no data is at best noise.America/New_York, not UTC. The schedule is pinned to the market's timezone, so 7:15 AM ET stays 7:15 AM ET through daylight saving. This is a deliberate choice and the right one for market data: the thing we care about — the exchange session — moves with New York, so our schedule should too. Had we written it in UTC, the job would silently drift an hour twice a year relative to the open.catchup=False. Airflow's default is the opposite: turn it on and a DAG with an old start_date will, on first deploy, try to run every day since — hundreds of runs, at once, on the smallest machine we own. catchup=False says "only run from now on." Backfills are a deliberate act, not a side effect of deploying.max_active_runs=1. Never two copies of this pipeline at once. If yesterday's run is somehow still going, today's waits.retries: 1 + retry_delay: 5 minutes. One retry, five minutes later. This is the "transient failure" bet — a vendor blip resolves in five minutes; a real bug doesn't, and burning ten retries on it just delays the email.email_on_failure: True. The whole alerting story. There's no dashboard and no pager — if a DAG fails, an email goes out, and someone has to read it.That's the detail most worth understanding, because it's a real constraint leaving a fingerprint on real code.
Airflow's scheduler re-parses every DAG file, constantly — that's how it notices changes. Parsing means executing everything at module level. So a top-level import pandas means pandas gets imported over and over, forever, just to work out what time it is.
On a small host, that's enough to blow Airflow's DAG-import timeout — at which point Airflow decides the DAG is broken and stops scheduling it. Your pipeline doesn't crash. It silently never runs.
Hence the pattern: keep the DAG file's module level trivially cheap, and defer the heavy imports into the task function, which only executes when the task actually runs. The comment in our DAGs says so outright.
7:00, 7:05, 7:15 — the DAGs are deliberately spaced a few minutes apart. Same reason: several pipelines starting simultaneously on a machine with under a gigabyte of RAM is memory pressure, and one of the DAGs says so in a comment right next to its schedule.
This is what infrastructure constraints look like in practice. Not an architecture diagram — a five-minute offset and a comment explaining it. When you see an odd-looking number in a config, assume it's load-bearing until you learn otherwise.
Not everything here runs under Airflow. A job that fires once a day can just as well run on plain cron:
30 9 * * * sh /app/scripts/run_live_trend.sh >> /var/log/cron.log 2>&1
One line, one job, once a day at 09:30. Note what you give up versus Airflow: no run history, no retry policy, no UI, no failure email. And note the two details that still matter:
>> ... 2>&1 — send stdout and stderr to a log file. Without 2>&1, the error messages — the only part you actually want — go nowhere. Cron's default is to email them to a local mailbox nobody reads.PATH. Not your shell's, anyway. This is the single most common cron failure: a bare python that works in your terminal and doesn't exist in cron's environment. Use absolute paths for everything.Why have both? Airflow earns its keep when you have several jobs, want retries, and benefit from run history. Cron is fine when one line is genuinely all you need — reaching for a scheduler to run a single daily binary is more machinery than problem. Match the tool to the job rather than standardizing for its own sake.
When one job's output is another's input, something has to sequence them. The blunt approach is a time gap: run the producer early, the consumer hours later, and rely on the slack. It's simple and it usually works.
What it costs is worth naming. A gap is not a dependency — the consumer doesn't know the producer succeeded, it just reads whatever is there and trusts it. Overrun the gap and the consumer runs on yesterday's data with no complaint from anyone. The alternative is an explicit dependency, where the consumer waits for a real signal. Either is defensible; drifting into the first without noticing you chose it is not.
Schedulers retry. Humans re-run things by hand. A job that corrupts data when it runs twice is a job that will corrupt data.
The property you want is idempotency — running it again produces the same end state, not a doubled one. For a database write, that's usually one clause:
-- Not this: a re-run silently doubles rows
INSERT INTO some_table (...) VALUES (...);
-- This: a re-run changes nothing
INSERT INTO some_table (...) VALUES (...)
ON CONFLICT DO NOTHING;
Idempotency is what makes "just re-run it" a safe first response to a failure — and that matters more than it sounds, because it's the difference between a 3am incident you can act on and one you have to reason about first.
Know what the clause means, though: DO NOTHING skips rows that already exist, so a re-run fills gaps but never overwrites. DO UPDATE would be the opposite choice. Which one you want depends on whether a later fetch of the same day should be treated as a correction or as a duplicate.
Your code needs vendor API keys and the database password. None of them go in the repository — not in a config file, not in a notebook, not in a docstring, not "temporarily."
Why this is absolute: git history is permanent. A key committed and deleted in the next commit is still in the history, still on every clone, and still on the remote. Removing it means rewriting history for everyone. And a leaked vendor key is a bill someone else runs up on the fund's subscription.
The mechanism is a .env file that is never committed. What is committed is .env.template — the shape, with placeholders and nothing real:
# .env.template (committed — placeholders only)
VENDOR_API_KEY=vendor-api-key
DB_HOST=db-host
DB_PORT=db-port
DB_USER=db-user
DB_PASSWORD=db-password
DB_NAME=db-name
You copy it to .env, fill in real values, and .env is gitignored. Code reads them from the environment:
from dotenv import load_dotenv
import os
load_dotenv()
key = os.environ["VENDOR_API_KEY"] # KeyError immediately if unset
The template is doing real work here, and it's a pattern worth reusing. It documents which variables the app needs without containing a single secret — so a new engineer can set up in minutes, and the list of required config never drifts out of date, because the app fails loudly the moment one is missing.
Use os.environ["KEY"], not os.environ.get("KEY"). The bracket form fails immediately and loudly when the variable is missing. .get() returns None, which surfaces later as a confusing auth error — or worse, as a request that quietly returns no rows.
Say so immediately — that day. The fix is to rotate the key, and rotation is routine. The failure mode that actually costs the fund is a key sitting in history for months because someone was embarrassed. Nobody is in trouble for reporting it; the only bad outcome is silence.
except: pass turns a loud failure into a stale table that gets traded on for weeks. A deliberate skip still logs — skipping and hiding are different..env, a fraction of the memory. "Works for me" is not a deployment.catchup on. The default is True. Deploy a DAG with an old start_date and it will try to run every day since, all at once, on the smallest machine we own.ON CONFLICT DO NOTHING means a re-run won't overwrite what's already there. Re-running fills gaps; it does not correct rows.