All labs

Quant correctness lab · Quantitative Engineering

Build a backtest that cannot see the future

Implement a tiny moving-average strategy, lag the position by one bar, and add assertions that fail when the backtest accidentally trades on information from the same close.

45–60 min Intermediate Python · pandas

Lab outcome

A reproducible toy backtest with an explicit signal/position boundary and automated look-ahead checks.

Before you start

  • Python 3.11 or newer.
  • Basic pandas Series and DataFrame knowledge.
  • A fresh working directory or virtual environment with pandas installed.

Guided procedure

Complete the lab

01

Create a deterministic price series

Start with synthetic data so everyone gets the same result and there are no API keys, missing dates, corporate actions, or data-vendor assumptions.

backtest.pypython
import pandas as pd

prices = pd.Series(
    [100, 101, 99, 103, 105, 102, 106, 109],
    index=pd.date_range("2026-01-01", periods=8, freq="D"),
    name="close",
)

returns = prices.pct_change().fillna(0.0)

Checkpoint

  • The series contains eight ordered timestamps.
  • The first return is zero and later returns use adjacent closes.
02

Separate signal time from execution time

The signal is calculated at the close of bar t. The shift makes the position available from bar t+1, preventing a trade from using the same close that created its signal.

Append to backtest.pypython
moving_average = prices.rolling(3).mean()
signal = (prices > moving_average).astype(int)
position = signal.shift(1).fillna(0).astype(int)

strategy_return = position * returns
equity = (1 + strategy_return).cumprod()

Checkpoint

  • position begins at zero.
  • Every later position equals the previous row's signal.
03

Create an audit table

Put every intermediate value beside the result. A backtest is easier to trust when the calculation path is inspectable row by row.

Append to backtest.pypython
result = pd.DataFrame(
    {
        "close": prices,
        "moving_average": moving_average,
        "signal": signal,
        "position": position,
        "return": returns,
        "strategy_return": strategy_return,
        "equity": equity,
    }
)

print(result.round(4).to_string())

Checkpoint

  • The signal and position columns are both visible.
  • Equity changes only when position equals one.
04

Turn the bias rule into executable tests

Assertions make the timing contract part of the program instead of a comment that future edits can accidentally violate.

Append to backtest.pypython
assert position.iloc[0] == 0
assert position.iloc[1:].tolist() == signal.iloc[:-1].tolist()
assert result["equity"].notna().all()
assert (result["equity"] > 0).all()

print("\nPASS: positions are lagged and the equity curve is valid.")
Runpowershell
python .\backtest.py

Checkpoint

  • The script prints PASS.
  • Removing shift(1) makes the timing assertion fail.

Verification

  • No position uses the signal from the same row.
  • Every calculation is visible in the audit DataFrame.
  • The script fails when the one-bar lag is removed.
  • The exercise does not depend on live or paid market data.

Cleanup

No external resources were created. Keep backtest.py as your deliverable, or delete only the dedicated lab directory you created after confirming its path.