sifting/io
Developer Tutorials
7 min readSiftingIO Team

Normalize prices to 100 in Python: compare market data

Compare forex, gold, stocks and crypto on one chart. Rebase prices to 100 in Python with a shared baseline, tested pandas code and clear handling of gaps.

Normalize prices to 100 in Python: compare market data

Putting EURUSD, gold, a US stock and Bitcoin on one price axis usually tells you more about their units than their movement. A price near 1 disappears beside a price near 50,000. Normalize prices to 100, and every series starts at the same height: 103 means its quoted price is 3% above its starting value.

This Python example builds that comparison with pandas and Matplotlib. It uses a small, fictional dataset so you can check the arithmetic without an API key. To try it with your own selection, download prices from the market data export tool, or create a free SiftingIO account for API access.

What rebasing a price to 100 means#

Choose one baseline time, then calculate each series separately:

indexed price = 100 × current price / price at the baseline

The result has no currency unit. It measures the proportional change in the input price from that baseline. These fictional examples all cover the same observation period:

SeriesStarting priceLater priceRebased valuePrice change
EURUSD, USD per EUR1.10001.1110101+1%
Gold, USD per troy ounce2,0002,060103+3%
US stock, USD per share20019899−1%
Bitcoin, USD per BTC50,00052,000104+4%

The Bitcoin line ends highest because its input price rose most in percentage terms over this example period. Its larger dollar price has no effect on that ranking. Changing dollars to cents would produce the same indexed line.

An indexed value of 103 is three index points above the 100 baseline, corresponding to a 3% price increase. A move from 103 to 104 is about 0.97%, not 1%.

Give every line the same starting time#

A common mistake is dividing each series by its own first available price. If the stock starts on Monday and the crypto series starts on Saturday, both lines begin at 100 but describe different periods.

The helper below finds the earliest row with a value for every selected series. It uses that row as the shared baseline and removes earlier rows. Missing values after that point stay missing.

The input must already contain comparable observations. A timezone-aware index makes timestamps explicit; it does not prove that the underlying prices were observed at the same moment. A daily stock close and a midnight crypto close can share a calendar date while referring to different times. For that preparation step, see aligning stock and crypto prices to the same closing time.

Install the plotting dependencies in your Python environment:

python -m pip install pandas numpy matplotlib
import numpy as np
import pandas as pd


def rebase_to_100(prices):
    if prices.empty:
        raise ValueError("Provide at least one row and one price series")
    if not isinstance(prices.index, pd.DatetimeIndex):
        raise ValueError("Use a DatetimeIndex for observation times")
    if prices.index.tz is None or prices.index.hasnans:
        raise ValueError("Observation times must have a timezone and no NaT")
    if prices.index.has_duplicates or prices.columns.has_duplicates:
        raise ValueError("Resolve duplicate timestamps or series names first")

    values = prices.sort_index().astype("float64")
    if np.isinf(values.to_numpy()).any() or values.le(0).any(axis=None):
        raise ValueError("Observed prices must be positive and finite")

    complete = values.dropna(how="any")
    if complete.empty:
        raise ValueError("No shared baseline has prices for every series")

    baseline_at = complete.index[0]
    baseline = complete.iloc[0]
    indexed = values.loc[baseline_at:].div(baseline, axis="columns").mul(100)
    return indexed, baseline_at

The division matches each baseline price to its column name, as specified by pandas DataFrame.div. The helper uses dropna only to select the baseline. It does not delete every incomplete row from the final chart.

Zero and negative prices are rejected because this chart assumes positive price ratios. Use a different comparison method for a series that can cross zero. Floating-point arithmetic is suitable for this display calculation; this is not a money-settlement function.

Run a four-market example#

Run this after the helper. All values below are invented observations at matching times, not historical market quotes or SiftingIO API responses. The labels describe the example series, not a complete instrument specification.

times = pd.to_datetime([
    "2026-10-12T14:00:00Z",
    "2026-10-12T15:00:00Z",
    "2026-10-12T16:00:00Z",
    "2026-10-12T17:00:00Z",
])

prices = pd.DataFrame({
    "EURUSD": [1.1000, 1.1022, 1.1055, 1.1110],
    "Gold": [2000.0, 2020.0, 2010.0, 2060.0],
    "US stock": [200.0, 201.0, 199.0, 198.0],
    "Bitcoin": [50000.0, 51000.0, 50500.0, 52000.0],
}, index=times)

indexed, baseline_at = rebase_to_100(prices)
print(indexed.iloc[-1].round(2).to_dict())
# {'EURUSD': 101.0, 'Gold': 103.0, 'US stock': 99.0, 'Bitcoin': 104.0}

Keep the original prices alongside the indexed values. A tooltip can then show both the actual quote and its change from the baseline. Label the chart's starting time so readers can see which period they are comparing.

import matplotlib.pyplot as plt
import matplotlib.dates as mdates

fig, ax = plt.subplots(figsize=(10, 5), layout="constrained")
for name in indexed.columns:
    ax.plot(indexed.index, indexed[name], marker="o", label=name)

ax.axhline(100, color="gray", linewidth=0.8, linestyle="--")
ax.set_title("Price comparison: fictional example")
ax.set_ylabel("Price index (common baseline = 100)")
ax.set_xlabel(f"UTC; baseline {baseline_at.strftime('%Y-%m-%d %H:%M')}")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M", tz=indexed.index.tz))
ax.legend(frameon=False)
ax.grid(axis="y", alpha=0.2)
fig.savefig("rebased-prices.png", dpi=180)
plt.show()

The saved image should show four lines beginning at 100. Later gaps in an input column remain gaps in that line: Matplotlib breaks lines at NaN values. An entirely absent timestamp needs to be represented in your chosen observation grid before it can appear as a gap. The helper does not build that grid or infer market hours.

Replace the example with real market data#

SiftingIO provides historical price data for forex, commodities, US stocks and crypto. The export tool lets you select instruments and download CSV or Excel files. For an automated pipeline, use the corresponding historical endpoints in the API documentation.

For each series, keep the instrument, quote currency, interval and price definition with the file. The CSV export's Close column contains the bar close, and the Timestamp (ms) column records the bar's instant in Unix milliseconds. Confirm whether that timestamp marks the open or the close of the bar for your chosen interval before treating it as an observation time.

Prepare a table with one column per series and one agreed observation time per row, then pass it to the helper. Use completed periods. Keep a missing observation empty rather than replacing it with zero or silently carrying a Friday price through the weekend.

If a newly added series begins later, the shared baseline will move later too. Show that date change in the interface rather than presenting the refreshed chart as the same comparison.

What the chart can and cannot tell you#

Rebasing makes relative price movement readable. It leaves the meaning of each input unchanged.

CheckWhy it matters
FX pair directionA rising EURUSD means more USD per EUR. A rising USDJPY means more JPY per USD. The upward lines describe different base currencies.
Quote currencyDividing by a baseline does not convert a EUR-priced asset into USD. Convert consistently if the question is performance in one reporting currency.
Stock adjustmentsA price series that is not adjusted for splits and dividends shows a step at a split that is not a real change in value. Check how your source treats corporate actions before comparing a stock across one.
Price return versus total returnDividends, interest, FX carry, fees and trading costs are not added by this calculation. A spot commodity price is also different from a rolling futures investment.
Volatility and riskStarting every series at 100 does not give them equal volatility or make the highest line the best investment.

For this chart, "normalize" means rebasing to a starting value of 100. Min-max scaling and z-scores answer different questions. Keep the transformation, baseline time and price definitions visible, and the comparison becomes much easier to read without changing the underlying data.

To build your own version, choose instruments in the market data export tool and start with a short, completed period. Check the observation times first, then apply the same baseline across every series.

Keep reading

Related posts