Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Unit Testing Market-Data Transformations and Indicators

Hello. In the previous lesson, you separated reusable trading logic from notebook exploration. That decision makes this lesson possible: a function such as add_session_date or regular_session_mask can now be checked independently, without starting Jupyter, downloading data, or calling a broker API.

This lesson introduces focused unit tests for deterministic market-data transformations and indicators. You will use pytest to turn trading rules into executable specifications: known timestamps must map to known session dates; session boundaries must behave exactly as stated; and known prices must produce known returns and moving averages.

Study time: about 40 minutes.


A unit test is a contract for a trading rule

A unit test runs one small, deterministic piece of code with controlled inputs and checks its output against an expected result.

For example, this is a useful trading-data contract:

A start-labelled NSE equity bar at 09:15 Asia/Kolkata is included in the regular session, while a bar at 15:30 is excluded.

That statement is more valuable than “the session filter seems to work.” It specifies the edge conditions that determine whether a bar is eligible for a signal. If a future refactor changes < to <=, the test should fail immediately.

Unit tests are particularly suitable for code that has:

  • explicit inputs and outputs;
  • no network calls, files, broker APIs, or current-time dependency;
  • stable expected behavior;
  • material consequences if wrong.

That includes:

Code area Good unit-test target Why it matters
Market time Convert aware timestamps to Indian session dates Prevents incorrect daily grouping
Session filtering Include 09:15, exclude 15:30 Prevents invalid bars entering features
Market-data transforms Sort, deduplicate, calculate returns Prevents corrupted research inputs
Indicators Moving averages, volatility, ATR, momentum Protects signal calculations
Validation Reject missing columns or naive timestamps Fails clearly instead of silently guessing

It does not include whether Kite Connect is currently available or whether an NSE trading day is a holiday. Those are integration and operational concerns that will receive different kinds of tests later.

A good unit test does not prove that a strategy will make money. It establishes a narrower, vital claim: this declared calculation still behaves as intended.

Please Learn How To Write Tests in Python… • Pytest Tutorial

Watch “Please Learn How To Write Tests in Python… • Pytest Tutorial” by Tech With Tim for a concise introduction to pytest’s plain-assert style and parameterized tests.

Watch pytest basics to see how pytest discovers test_ files and functions, runs assertions, and reports failures. Then watch parameterized cases for the pattern used when the same trading rule needs several input and expected-output cases.


Give tests a predictable home

Continue using the project structure established in the previous lesson:

algo-trader/
├── src/
│   └── algo_trader/
│       ├── market_time.py
│       └── features.py
└── tests/
    ├── test_market_time.py
    └── test_features.py

The relationship should be easy to see:

  • src/algo_trader/market_time.py contains session-date and session-filtering rules.
  • tests/test_market_time.py verifies those rules.
  • src/algo_trader/features.py contains returns and indicator calculations.
  • tests/test_features.py verifies their numerical behavior.

From the project root, install pytest in the same isolated environment that contains your package:

python -m pip install pytest

Run the complete suite with:

python -m pytest

While developing one area, run only its tests:

python -m pytest tests/test_market_time.py -q

Pytest discovers files beginning with test_ and functions beginning with test_. Prefer descriptive names that reveal the business rule being protected:

def test_regular_session_mask_includes_open_and_excludes_close():
    ...

That name is already a compact specification. If it fails months later, you should understand the intended behavior before opening the implementation.

The body of most tests follows arrange, act, assert:

  1. Arrange known, minimal input data and an expected result.
  2. Act by calling exactly the function under test.
  3. Assert the result matches the contract.

The expected result should usually be written as a known answer, rather than produced by repeating the production calculation in the test. If your test computes a moving average with the same logic as the implementation, both can contain the same mistake and still pass.


Use fixtures for small, fresh market-data samples

A fixture is a named setup function that pytest supplies to a test that requests it as an argument. For data transformations, fixtures are ideal for compact DataFrames and Series that appear in more than one test.

How to use fixtures

Read the pytest documentation’s fixture introduction. It explains how tests receive declared setup data and why pytest gives tests fresh fixture results by default.

In “Requesting fixtures,” read fixture injection. Then continue into “Fixtures are reusable,” focusing on fresh test data. In this course, fixtures will usually create small in-memory price series or bar tables, never download live market data.

Suppose your previous market_time.py contains the functions:

add_session_date(bars, timestamp_col="timestamp_ist")
regular_session_mask(timestamps)

A compact fixture can provide two timezone-aware timestamps. The first is 09:15 in India; the second is after midnight in India even though its original timestamp is still on the previous UTC date.

# tests/test_market_time.py
import pandas as pd
import pytest
from pandas.testing import assert_frame_equal, assert_series_equal

from algo_trader.market_time import add_session_date, regular_session_mask


@pytest.fixture
def utc_bars() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "timestamp": pd.to_datetime(
                [
                    "2024-01-02 03:45:00+00:00",
                    "2024-01-02 18:45:00+00:00",
                ],
                utc=True,
            ),
            "close": [1_500.0, 1_512.0],
        }
    )

A test can request this fixture merely by naming it in its parameter list:

def test_add_session_date_converts_to_indian_calendar_date(
    utc_bars: pd.DataFrame,
) -> None:
    # Arrange
    original = utc_bars.copy(deep=True)
    expected_dates = pd.Series(
        pd.to_datetime(["2024-01-02", "2024-01-03"]),
        name="session_date",
    )

    # Act
    actual = add_session_date(utc_bars, timestamp_col="timestamp")

    # Assert
    assert_series_equal(actual["session_date"], expected_dates)
    assert_frame_equal(utc_bars, original)

There are two important assertions here:

  1. The UTC timestamps map to the intended dates in Asia/Kolkata.
  2. The input DataFrame remains unchanged.

The second assertion protects the explicit contract from the previous lesson: reusable transformations should return a result rather than silently mutate a caller’s object. Mutation bugs are especially awkward in notebooks, where a DataFrame may be reused many cells later.

Keep fixtures deliberately small. A four-row price series makes a failed moving-average test understandable immediately. A fixture that loads a multi-year CSV or requests broker data is slower, less reliable, and belongs in a separate integration-test category.


Test boundaries, not only typical examples

Most expensive data bugs occur at boundaries: just before the market opens, exactly at the open, just before the close, and exactly at the close.

The regular_session_mask function defined in the previous lesson treats the session as start-labelled bars from 09:15 inclusive through 15:30 exclusive. Encode all four boundaries in one test:

def test_regular_session_mask_includes_open_and_excludes_close() -> None:
    # Arrange
    timestamps = pd.Series(
        pd.to_datetime(
            [
                "2024-01-02 03:44:59+00:00",  # 09:14:59 IST
                "2024-01-02 03:45:00+00:00",  # 09:15:00 IST
                "2024-01-02 09:59:59+00:00",  # 15:29:59 IST
                "2024-01-02 10:00:00+00:00",  # 15:30:00 IST
            ],
            utc=True,
        )
    )

    # Act
    actual = regular_session_mask(timestamps)

    # Assert
    expected = pd.Series([False, True, True, False])
    assert_series_equal(actual, expected)

This test is stronger than testing only a bar at noon. A noon bar would still pass if the developer accidentally shifted the opening boundary by several minutes.

Tests should also specify how invalid inputs fail. The earlier function deliberately rejects naive timestamps because Python cannot safely infer whether a naive value such as 2024-01-02 09:15:00 means UTC, Indian time, or something else.

def test_regular_session_mask_rejects_naive_timestamps() -> None:
    naive_timestamps = pd.Series(
        pd.to_datetime(["2024-01-02 09:15:00"])
    )

    with pytest.raises(ValueError, match="timezone-aware"):
        regular_session_mask(naive_timestamps)

An exception test is not testing for failure in general. It verifies a chosen safety behavior: ambiguous timestamps must cause a clear error rather than produce quietly incorrect session membership.

When several cases use the same shape, use pytest.mark.parametrize. This keeps the test readable while producing a separate pytest result for each policy case:

@pytest.mark.parametrize(
    ("timestamp", "expected"),
    [
        ("2024-01-02 03:44:59+00:00", False),
        ("2024-01-02 03:45:00+00:00", True),
        ("2024-01-02 09:59:59+00:00", True),
        ("2024-01-02 10:00:00+00:00", False),
    ],
)
def test_regular_session_boundaries(
    timestamp: str,
    expected: bool,
) -> None:
    timestamps = pd.Series(pd.to_datetime([timestamp], utc=True))

    assert regular_session_mask(timestamps).iloc[0] is expected

Use parameterization when each row has one clear meaning. Avoid turning one test into a sprawling grid with many unrelated flags; separate tests remain easier to diagnose.


Test indicator calculations using known prices

The same discipline applies to indicators. Before testing, decide the definition.

For this lesson, use these explicit conventions:

  • Simple one-period return: current close divided by prior close, minus one.
  • Three-period simple moving average: mean of the current close and previous two closes.
  • Warm-up period: return NaN until enough observations exist.
  • Rolling window direction: trailing only; no future price can affect a past indicator value.

A simple, testable features.py interface might be:

import pandas as pd


def simple_returns(close: pd.Series) -> pd.Series:
    return close.pct_change(fill_method=None)


def simple_moving_average(
    close: pd.Series,
    window: int,
) -> pd.Series:
    if window < 1:
        raise ValueError("window must be positive")

    return close.rolling(
        window=window,
        min_periods=window,
    ).mean()

The tests should not merely verify that these functions return a Series. They should check the financial meaning of the values.

# tests/test_features.py
import pandas as pd
import pytest
from pandas.testing import assert_series_equal

from algo_trader.features import simple_moving_average, simple_returns


@pytest.fixture
def close_prices() -> pd.Series:
    return pd.Series(
        [100.0, 110.0, 99.0, 99.0],
        index=pd.to_datetime(
            [
                "2024-01-02",
                "2024-01-03",
                "2024-01-04",
                "2024-01-05",
            ]
        ),
        name="close",
    )


def test_simple_returns_have_expected_values(
    close_prices: pd.Series,
) -> None:
    original = close_prices.copy()

    actual = simple_returns(close_prices)

    assert actual.index.equals(close_prices.index)
    assert pd.isna(actual.iloc[0])
    assert actual.iloc[1] == pytest.approx(0.10)
    assert actual.iloc[2] == pytest.approx(-0.10)
    assert actual.iloc[3] == pytest.approx(0.00)
    assert_series_equal(close_prices, original)

The prices were selected because the expected answers are easy to calculate manually:

  • From 100 to 110 is a 10 percent return.
  • From 110 to 99 is a negative 10 percent return.
  • From 99 to 99 is zero.

For floating-point outputs, pytest.approx is usually preferable to direct equality. Decimal values such as 0.1 do not always have an exact binary floating-point representation. Use approximation for computed real-number results, not as a way to hide large errors.

Now test the moving average:

def test_three_period_moving_average_has_expected_warmup_and_values(
    close_prices: pd.Series,
) -> None:
    actual = simple_moving_average(close_prices, window=3)

    assert actual.index.equals(close_prices.index)
    assert pd.isna(actual.iloc[0])
    assert pd.isna(actual.iloc[1])
    assert actual.iloc[2] == pytest.approx(103.0)
    assert actual.iloc[3] == pytest.approx(102.6666666667)

The first available value occurs on the third observation:

The next value uses 110, 99, and 99. The oldest price has rolled out of the window.

The leading NaN values are not an inconvenience to ignore. They are part of the feature specification. If a future implementation uses min_periods=1, it may generate early values that change the eligibility of a trade. A test makes that policy visible.


Protect against accidental look-ahead in rolling features

A trailing rolling indicator at an earlier date must not change because a later price changes. This is an effective property to test, especially because a mistaken centered window can look plausible on a chart while leaking future information.

def test_moving_average_does_not_use_future_prices(
    close_prices: pd.Series,
) -> None:
    altered_prices = close_prices.copy()
    altered_prices.iloc[-1] = 1_000.0

    original_sma = simple_moving_average(close_prices, window=3)
    altered_sma = simple_moving_average(altered_prices, window=3)

    assert_series_equal(
        original_sma.iloc[:-1],
        altered_sma.iloc[:-1],
    )

Only the final moving-average value is allowed to change after changing the final price. Every earlier value must remain identical.

This kind of test does not replace a full review of strategy timing. In a future backtest, you will still need to ensure that a signal formed from a daily close is not executed at that same close unless your simulation defines a genuinely available mechanism. But it does protect the local calculation from one common source of leakage.

Finally, encode invalid parameter behavior too:

@pytest.mark.parametrize("window", [0, -3])
def test_moving_average_requires_positive_window(window: int) -> None:
    close = pd.Series([100.0, 101.0, 102.0])

    with pytest.raises(ValueError, match="window must be positive"):
        simple_moving_average(close, window=window)

The precise exception type and message are less important than having an intentional rule. A nonsensical indicator parameter should be rejected at the smallest possible boundary, not discovered only after a backtest produces confusing results.


What to test, and what not to test

For each deterministic function, write down its contract before adding cases. A practical checklist is:

Contract question Example for trading data
What valid inputs are accepted? Timezone-aware timestamp Series
What output is expected? Boolean session-membership mask with preserved index
What happens at boundaries? 09:15 included; 15:30 excluded
What happens before enough history exists? Indicator returns NaN
What invalid inputs are rejected? Naive timestamps; non-positive window
What must not change? Caller’s input DataFrame or Series
What information is forbidden? Future prices in a trailing feature

Do not attempt to test pandas itself. You are testing your intended policy around pandas: your time-zone assumptions, boundary choices, indicator naming, warm-up behavior, and validation rules.

Likewise, avoid live requests in unit tests. A unit test that downloads current prices can fail because of a network outage, expired credentials, rate limits, vendor changes, or simply a different current date. It tells you nothing reliable about your return calculation. Later, integration tests will test your downloader separately using controlled responses or dedicated test datasets.

A sensible development loop is:

  1. Define the rule in plain language.
  2. Create the smallest input that demonstrates the rule.
  3. Write the expected output by hand.
  4. Run the test and observe it fail if the behavior is not implemented.
  5. Implement or revise the function.
  6. Run the full suite before committing.
  7. When a real bug is found, add a regression test that would have caught it.

Key takeaways

Unit tests turn critical deterministic trading rules into checks that run automatically.

  • Keep tests separate from source code and run them from the project root with python -m pytest.
  • Use fixtures to create small, fresh, in-memory DataFrames and Series.
  • Test known values, boundaries, invalid inputs, index preservation, and non-mutation.
  • Specify indicator warm-up behavior rather than silently dropping or filling missing values.
  • Use pytest.approx for floating-point calculations where exact binary equality is inappropriate.
  • Test the causal property of trailing indicators: future prices must not alter past feature values.
  • Keep broker APIs, downloaded files, and live market data out of unit tests.

Next, you will move hard-coded strategy values into validated configuration. The same testing mindset will apply: a configuration value such as an SMA window, maximum position count, or session cutoff should be accepted only when it satisfies explicit constraints.

Can't find a good explanation? Sign up and we'll make it for you

Sign up