CUCKOOTRADE
Guide

How to run market data tests in CI without API keys

No secrets to store, no rate limit to trip, no red build on a Sunday, and assertions that stay true next year. Copy-paste setups for GitHub Actions, GitLab CI, and docker-compose.

CI/CD Docker 10 minutes

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:

yaml — .github/workflows/test.yml
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.

When not to use this. The hosted service allows 60 requests/minute per address with a burst of 120. A busy runner pool shares addresses, and it is an external dependency your build now has. For anything beyond a light test suite, use option 2.

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:

yaml — .github/workflows/test.yml
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

yaml — .gitlab-ci.yml
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

yaml — docker-compose.test.yml
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:

python — the only line that changes
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:

python — tests/test_market_data.py
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 touch SPLITS, DIVVY, or REVISED, whose history deliberately moves over time. Pin it and those become byte-stable too.
Use a fixed window, not a relative one. 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:

python — failure paths in the same suite
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.

Next