Four ways market data breaks a pipeline
- Secrets. A provider key has to exist in CI, which means it cannot run on pull requests from forks without either leaking the key or skipping the tests. Usually the tests get skipped.
- Rate limits. CI runners share egress addresses. Your per-key or per-IP quota is consumed by whichever build happened to run first, and the failure looks like a bug in your code.
- The calendar. A test that fetches “the last five days” returns four bars on a Tuesday and zero on a Sunday. Nightly builds discover this; developers do not.
- Moving values. Any assertion tighter than “the response parsed” expires, so most market data tests assert almost nothing.
A deterministic, keyless server removes all four, and it does it without a mocking layer — the code under test still makes real HTTP requests through its real client library.
Option 1: point at the hosted endpoint
The fastest version. One environment variable, no services, no secrets:
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
env:
MARKET_DATA_URL: https://cuckootrade.com/api/v1/alpaca
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/
Note what is not there: no secrets. reference,
so this workflow runs identically on a pull request from a fork.
Option 2: run it as a CI service (recommended)
The container is stateless and starts in about a second. Determinism means it serves byte-identical data to the hosted service, so tests written against one work against the other:
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
market-data:
image: ghcr.io/tj-miller-dev/cuckootrade
ports: ["8000:8000"]
env:
MARKET_DATA_URL: http://localhost:8000/api/v1/alpaca
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
# Wait for the service before the suite starts.
- run: |
for i in $(seq 1 30); do
curl -sf http://localhost:8000/api/health && break
sleep 1
done
- run: pytest tests/
No egress, no rate limit, no third-party uptime in your build.
GitLab CI
test:
image: python:3.12
services:
- name: ghcr.io/tj-miller-dev/cuckootrade
alias: market-data
variables:
MARKET_DATA_URL: "http://market-data:8000/api/v1/alpaca"
script:
- pip install -r requirements.txt
- pytest tests/
docker-compose, for local parity
services:
market-data:
image: ghcr.io/tj-miller-dev/cuckootrade
ports: ["8000:8000"]
tests:
build: .
depends_on: [market-data]
environment:
MARKET_DATA_URL: http://market-data:8000/api/v1/alpaca
command: pytest tests/
Wiring it into the code under test
The goal is one environment variable and no test-only branches. For
alpaca-py:
import os
from alpaca.data.historical import StockHistoricalDataClient
def market_data_client():
override = os.getenv("MARKET_DATA_URL") # unset in production
return StockHistoricalDataClient(
api_key=os.getenv("ALPACA_KEY", "any"),
secret_key=os.getenv("ALPACA_SECRET", "any"),
**({"url_override": override} if override else {}),
)
Production is the branch where the variable is unset, so the tested path and the shipped path are the same code.
Assertions that do not expire
This is what determinism actually buys. Every bar is a pure function of symbol, timestamp, generation, and seed, so you can assert on values rather than on shapes:
import os
import httpx
import pytest
BASE = os.environ["MARKET_DATA_URL"]
WINDOW = {"timeframe": "1Day", "start": "2026-07-01", "end": "2026-07-31",
"generation": 1} # pin the generator explicitly
def bars(symbol, **extra):
r = httpx.get(f"{BASE}/v2/stocks/bars",
params={"symbols": symbol, **WINDOW, **extra})
r.raise_for_status()
return r.json()["bars"][symbol]
def test_window_is_reproducible():
assert bars("AAPL") == bars("AAPL")
def test_calendar_is_respected():
from datetime import date
for bar in bars("AAPL"):
day = date.fromisoformat(bar["t"][:10])
assert day.weekday() < 5 # never a weekend
# July 3 2026 is the observed Independence Day holiday (the 4th is a Saturday)
assert "2026-07-03" not in [b["t"][:10] for b in bars("AAPL")]
def test_seeds_give_independent_worlds():
assert bars("AAPL", seed="alpha") != bars("AAPL", seed="beta")
@pytest.mark.parametrize("symbol", ["CRASH", "GAPPY", "FLAT", "PENNY"])
def test_pipeline_survives_pathological_symbols(symbol):
assert len(bars(symbol)) > 0 # then assert on your own handling
Two parameters are worth pinning deliberately in CI:
generation=1— the generator version. If the engine is ever improved, that becomes generation 2 and generation 1 keeps answering exactly as it does today. Pinning makes the guarantee explicit rather than implicit.as_of=— pins the restatement axis. Only needed if your tests touchSPLITS,DIVVY, orREVISED, whose history deliberately moves over time. Pin it and those become byte-stable too.
start
and end as literal dates are reproducible; “the
last 30 days” is a moving target that will eventually cross a
weekend boundary and change the bar count.
Testing the failure paths too
Since the server is under your control in CI, the outage paths become testable as ordinary tests — deterministically, so they are safe to keep:
def test_client_surfaces_a_503():
r = httpx.get(f"{BASE}/v2/stocks/bars",
params={"symbols": "AAPL", "scenario": "status:503"})
assert r.status_code == 503
def test_client_times_out_rather_than_hanging():
with pytest.raises(httpx.ReadTimeout):
httpx.get(f"{BASE}/v2/stocks/bars",
params={"symbols": "AAPL", "scenario": "slow:3000"},
timeout=1.0)
Running the container locally also gives an exact
flap:n count, which the multi-replica hosted service
cannot promise.
The full fault-injection guide →
One free optimisation
Any fully-specified request whose end is in the past is
immutable by construction, and says so:
Cache-Control: public, max-age=31536000, immutable. If
your CI has an HTTP cache, historical windows cost one request ever.
Requests carrying a fault are no-store, and requests
without as_of on a restating ticker are never marked
immutable — because for those, history is genuinely allowed to
move.