Strasmore Research
Deep Dives Matt ConnorBy Matt Connor

Reproducible Backtest in Python, No API Key

Run a reproducible backtest in Python with no API key: a pinned install plus deterministic sample data, and an honest read of what one equity curve hides.

A reproducible backtest in Python is one a stranger can re-run on a clean machine and get your exact numbers back, with no account and no API key in the way. Most tutorials fail that test on line one, where a live download hands the next reader slightly different price history than it handed the author. This walkthrough pins one open-source engine, quantjourney-bt at version 0.12.4, runs its bundled example with zero credentials, then uses real market data to show what a single clean run still cannot tell you.

What makes a backtest reproducible?

Reproducibility has a narrow, testable meaning here: a second person runs one command on a clean machine and gets your numbers back to the decimal. Two ordinary things break it.

The first is the code. A library at version 0.x carries no compatibility promise between minor releases. A rename, a changed default, a reordered column, and your script keeps running while quietly reporting something else.

The second is the data. A tutorial whose first line is a live download was never reproducible. Vendors revise history, adjust for splits, and backfill gaps, and the same script prints different numbers a month later. After the fact, you cannot separate a code change from a data change.

The pattern worth copying from this project is the pairing of a fixed engine version with a small bundled dataset that ships inside the package itself.

Pin the install: pip install quantjourney-bt==0.12.4

quantjourney-bt is the QuantJourney backtester, released under the Apache License 2.0 and requiring Python 3.11 or newer. Version 0.12.4 was published on 21 July 2026, and it is the version every command below refers to, as documented in August 2026.

Work inside an isolated environment. python3 -m venv .venv creates one, source .venv/bin/activate enters it, and python -m pip install -U pip updates the installer inside it. Then take the exact version: pip install quantjourney-bt==0.12.4.

The project documents the unpinned form, pip install quantjourney-bt. The ==0.12.4 part is your responsibility, and at 0.x it earns its keep. Write the pin down where the next person will find it: pip freeze > requirements.txt captures every resolved dependency, including the ones you never named.

Two optional extras exist. pip install "quantjourney-bt[wf]" adds Optuna for the walk-forward and optimization examples. pip install "quantjourney-bt[data]" adds a yfinance fallback used for benchmarks.

Apache-2.0 is permissive. You can use and modify the code commercially, you keep the licence and notice files with any redistribution, and contributors grant patent rights explicitly.

How to run the bundled SMA example with no API key

The repository ships a launcher script alongside fifty runnable example strategies, split across a weight-based path and an order-based path, with five walk-forward workflows among them. ./strategy.sh --list prints the catalog. ./strategy.sh example_weights_01_sma_daily --check imports a single strategy and touches no data at all, which is the quickest confirmation that an install is sound.

The demo run itself is one line: ./strategy.sh example_weights_01_sma_daily --sample-data --output /tmp/qj-sample

The --sample-data flag carries the whole point. The project describes the dataset behind it this way:

The sample dataset is intentionally small and reproducible. It is useful for install checks, report generation, and reading the engine flow without creating an account.
Source: quantjourney-bt README, version 0.12.4, read 6 August 2026.

The run writes a directory rather than a console verdict: summary.txt and summary.json, a metrics.csv, an equity_curve.csv next to its equity_curve.png, a dashboard.html, a plots/ folder, and a run_metadata.json recording how the run was configured. That last file is the one most people skip and the one that makes a result auditable a year later.

Read the resulting metrics honestly. The bundled dataset is small and illustrative, so the Sharpe ratio and the maximum drawdown printed in summary.txt describe a sample file. They are not evidence about a strategy, and treating them as a result is the first mistake available to you.

What the run does establish is worth having: the install works, and the full engine flow, from signal to target weights to reconstructed portfolio value, produces its artifacts on your machine without a single credential. A credentialed path to the project's own data service exists for backtests on real history. That path is documented, and this walkthrough stops here, at the part that needs nothing from anyone.

What one in-sample equity curve does not tell you

The sample run draws one equity curve. Here is what that curve cannot do, measured on real market data instead of a demo file.

The panel below takes the same idea the example strategy uses, a 20-session moving average crossing a 50-session one, applies it to SPY, and reports each calendar year separately from 2017 through 2025. The position in each session is fixed by the prior session's close, so the rule never trades on a number it did not have yet.

QueryA 20/50 moving-average crossover on SPY, year by year, against holding
The exact SQL behind every number
WITH daily AS
(
    SELECT
        toDate(toTimeZone(window_start, 'America/New_York')) AS d,
        toFloat64(argMax(close, window_start))               AS px
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker = 'SPY'
      AND window_start >= '2016-01-01 00:00:00'
      AND window_start <  '2026-01-01 05:00:00'
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
    GROUP BY d
),
averaged AS
(
    SELECT
        d,
        px,
        avg(px) OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
        avg(px) OVER (ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
        row_number() OVER (ORDER BY d)                                      AS session_no
    FROM daily
),
positioned AS
(
    SELECT
        d,
        px,
        if(session_no >= 50 AND fast_ma > slow_ma, 1, 0) AS long_today,
        lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
            OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
        lagInFrame(px, 1)
            OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
    FROM averaged
)
SELECT
    toYear(d)                                                                   AS year,
    round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS rule_pct,
    round((exp(sum(log(px / px_prior))) - 1) * 100, 1)                          AS hold_pct,
    countIf(long_today != long_prior)                                           AS crossover_count
FROM positioned
WHERE px_prior > 0
  AND toYear(d) >= 2017
GROUP BY year
ORDER BY year
Run this yourself

One unchanged rule, measured 9 separate times. Read down the two percent columns before reading anything else. In 2017 the rule finished the year at 16% against 19.4% for holding SPY over the same period. In 2025 those same two columns read 10.4% and 16.4%. The code is identical in both rows. Only the window moved.

The crossover column shows how thin the underlying evidence gets. 4 position changes across 2025 means a full year of equity curve rests on a handful of decisions, which is a very small sample to call a result.

Does the same rule behave the same way on other names?

Changing the date window is one way to interrogate a single curve. Changing the universe is the other. The panel below holds the parameters fixed and runs the identical rule on five liquid names over the five calendar years 2021 through 2025.

QueryThe same 20/50 rule on five liquid names, 2021 through 2025
The exact SQL behind every number
WITH daily AS
(
    SELECT
        ticker,
        toDate(toTimeZone(window_start, 'America/New_York')) AS d,
        toFloat64(argMax(close, window_start))               AS px
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker IN ('SPY', 'QQQ', 'AAPL', 'MSFT', 'KO')
      AND window_start >= '2020-07-01 00:00:00'
      AND window_start <  '2026-01-01 05:00:00'
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
    GROUP BY ticker, d
),
averaged AS
(
    SELECT
        ticker,
        d,
        px,
        avg(px) OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
        avg(px) OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
        row_number() OVER (PARTITION BY ticker ORDER BY d)                                      AS session_no
    FROM daily
),
positioned AS
(
    SELECT
        ticker,
        d,
        px,
        lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
            OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
        lagInFrame(px, 1)
            OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
    FROM averaged
)
SELECT
    ticker                                                                      AS symbol,
    round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS rule_pct,
    round((exp(sum(log(px / px_prior))) - 1) * 100, 1)                          AS hold_pct,
    round(avg(long_prior) * 100, 0)                                             AS days_long_pct
FROM positioned
WHERE px_prior > 0
  AND d >= toDate('2021-01-01')
GROUP BY symbol
ORDER BY rule_pct DESC
Run this yourself

QQQ sits at the top of the panel at 40.2%, and the bottom row, KO, comes in at 3.8%. The days_long_pct column reports how much of the window each version spent holding anything at all, 67% for the top row. One parameter set, five universes, and a spread wide enough that picking the winner after the fact says nothing about the run you have not made yet.

None of this is a recommendation to trade a crossover. The crossover is a measuring stick for the backtest, and the backtest is what we are measuring.

Where look-ahead bias creeps into a weights backtest

A weights-based engine turns a signal into target weights, simulates fills against those weights, then rebuilds portfolio value from the resulting positions. The failure hides in the join between the signal and the weight. If today's weight comes from today's close and then earns today's return, the backtest has traded on information that did not exist when the order would have gone in. That is look-ahead bias, and it raises no error. It simply makes everything look better.

The panel below runs both versions of one rule over the same SPY history.

QuerySame rule, prior-session signal against same-session signal, SPY by year
The exact SQL behind every number
WITH daily AS
(
    SELECT
        toDate(toTimeZone(window_start, 'America/New_York')) AS d,
        toFloat64(argMax(close, window_start))               AS px
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker = 'SPY'
      AND window_start >= '2016-01-01 00:00:00'
      AND window_start <  '2026-01-01 05:00:00'
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
    GROUP BY d
),
averaged AS
(
    SELECT
        d,
        px,
        avg(px) OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
        avg(px) OVER (ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
        row_number() OVER (ORDER BY d)                                      AS session_no
    FROM daily
),
positioned AS
(
    SELECT
        d,
        px,
        if(session_no >= 50 AND fast_ma > slow_ma, 1, 0) AS long_today,
        lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
            OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
        lagInFrame(px, 1)
            OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
    FROM averaged
)
SELECT
    toYear(d)                                                                   AS year,
    round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS next_bar_pct,
    round((exp(sum(log(if(long_today = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS same_bar_pct,
    round(abs(exp(sum(log(if(long_today = 1, px / px_prior, 1.0))))
              - exp(sum(log(if(long_prior = 1, px / px_prior, 1.0))))) * 100, 1) AS gap_pp
FROM positioned
WHERE px_prior > 0
  AND toYear(d) >= 2017
GROUP BY year
ORDER BY year
Run this yourself

In 2017 the prior-session version printed 16% while the same-session version printed 17.1%, 1.1 percentage points apart. In 2025 the distance between the two measured 1.6 percentage points. Only one of those columns can be produced by a machine that did not already know where the session closed, and the difference between them is pure accounting, with no idea, no skill, and no trade behind it.

This engine states its own position on the timing question, which is worth more than a promise:

For fills at the open, range-sensitive slippage sees only the previous completed bar and volume capacity is forecast from lagged observations; the engine does not use that day's later high, low, close or full-day volume.
Source: quantjourney-bt README, version 0.12.4, read 6 August 2026.

A documented assumption can be checked against the source you already installed. An undocumented one is a guess.

Why the walk-forward extra exists

The walk-forward examples, WF01 through WF05, arrive with the [wf] extra and its Optuna dependency. Walk-forward fits parameters on one slice of history, measures them on the slice that follows, then rolls the pair forward and repeats. The rolling and expanding variants differ in whether the fitting window drops its oldest data as it advances. Another example adds a purge and embargo at each boundary, dropping the observations nearest the seam, so a fitted slice cannot leak into the slice it is being measured on.

None of that turns a weak idea into a working one. It replaces a single number with a distribution of numbers you can argue with, and that is the whole upgrade. The step after it is still not live money: paper trading before real money measures what a backtest structurally cannot see, starting with whether your order fills anywhere near the price the simulator assumed, and circuit breakers for trading bots cover what your code does on the day it does not. For the statistics underneath all of it, our notes on the open-source quant trading book go a level deeper.

FAQ

Can you backtest a strategy without an API key?

Yes. quantjourney-bt ships a bundled sample dataset behind a --sample-data flag, and its example strategies run against it with no account and no credentials. The dataset is small and illustrative, so treat that run as an install and pipeline check rather than as evidence about a strategy.

Why pin the version of a Python backtesting package?

A package at 0.x carries no compatibility guarantee between minor releases, and a changed default or renamed metric will not announce itself. Pinning with pip install quantjourney-bt==0.12.4 and recording the environment in a requirements file means a result you produce today can be rebuilt next year on the engine that produced it.

What licence is quantjourney-bt released under?

Apache License 2.0. It permits commercial use and modification, asks that you keep the licence and notice files with any redistribution, and includes an explicit patent grant from contributors. Version 0.12.4 was published on 21 July 2026 and requires Python 3.11 or newer.

Does a strong backtest result mean the strategy works?

No. A backtest is one measurement, over one window, on one universe. The panels above show a single unchanged rule producing very different yearly figures on one ticker and very different figures across five names, which is the gap walk-forward validation and out-of-sample testing are built to expose.

How the panels above were computed

Daily closes are the last regular-session minute print for each date, taken in New York clock time between 9:30 a.m. and 4:00 p.m., which keeps early-close half days correct without hardcoding a session length. The fast average covers 20 sessions and the slow average 50, both simple, and the first 49 sessions of every series are warm-up that holds no position. Yearly figures compound each session's close-to-close move for the sessions the rule was long, and the hold column compounds every session in the same year for comparison. The five names in the cross-section were picked for continuous histories with no split inside the window, so the close series needs no adjustment. Windows are fixed in the past, so these panels return the same numbers on every regeneration.


Every panel here carries the exact SQL beneath it, which makes the numbers on this page as re-runnable as the pinned install is. To measure a rule over your own window before you write any backtest code, ask the question in plain English on the Strasmore terminal.

#backtesting#python#open source#reproducibility#quantjourney