Skip to content

The Data Scientist

Benchmarking a Time Series Against an Index: A Case Study in Two-Step Normalisation

Comparing the performance of two assets sounds trivial until you try it. Prices live on different scales, respond to the same market-wide movements, and have wildly different volatilities. A naive comparison of two price series tells you almost nothing about which one is genuinely outperforming.

Finance solved a version of this problem decades ago with a technique that is worth examining for a reason that has little to do with trading: it is a clean, minimal example of two-step normalisation, and the second step contains a subtlety that is easy to get wrong in any domain.

The problem

You have two series, A and B, and a benchmark M. You want to know whether A is outperforming M, in a way that lets you track it over time.

Three approaches fail immediately:

Compare raw levels. A series trading at 400 and one trading at 12 produce numbers that cannot be meaningfully compared.

Compare returns over a fixed window. This works, but it collapses the whole history into one number and is sensitive to the endpoints you chose. Shift the window by a week and the answer changes.

Subtract the benchmark. Only valid if both are on the same scale, which they are not.

Step one: the ratio

The first move is to divide the series by the benchmark:

RP = (asset / benchmark) × 100

In the finance literature this is the Dorsey Relative Strength. The multiplication by 100 is cosmetic.

This removes the market-wide component. If both the asset and the benchmark rise 10 percent, the ratio is unchanged. What remains is the differential.

The ratio has a useful property and a serious flaw. The useful property is that its direction is meaningful: a rising ratio means the asset is outpacing the benchmark, full stop, regardless of whether either is going up or down in absolute terms.

The flaw is that its level is arbitrary. A ratio of 1.2 tells you nothing on its own, because the value depends entirely on the relative scales of the two inputs. You cannot compare 1.2 on one asset to 1.2 on another, and you cannot say whether 1.2 is high or low without knowing where it has been.

So the ratio is interpretable as a trend and uninterpretable as a level.

Step two: normalise against its own history

The fix is to express the current ratio as a deviation from its own trailing mean:

MRS = ((RP_t / SMA(RP, n)) − 1) × 100

Where SMA(RP, n) is the simple moving average of the ratio over n periods. In the standard implementation n is 52 on weekly data or 200 on daily, both approximating a year.

In pandas:

import pandas as pd  def mansfield_rs(asset: pd.Series,                  benchmark: pd.Series,                  n: int = 52) -> pd.Series:     “””Ratio of asset to benchmark, expressed as percentage     deviation from its own n-period trailing mean.”””     rp = (asset / benchmark) * 100     return ((rp / rp.rolling(n).mean()) – 1) * 100

The output now oscillates around zero. Zero is not an arbitrary threshold — it is the point at which the current ratio equals its own one-year average. Positive means the asset is outperforming the benchmark by more than it typically has. Negative means less.

This is the step that makes the level interpretable, and it is why this construction is preferred over the raw ratio. With the raw ratio only the trend carries information. After normalisation, the sign and magnitude do too.

The subtlety worth knowing about

Here is the part that catches people, and it generalises well beyond finance.

The normalised values are not comparable across series.

Each series is normalised against its own trailing mean. A reading of +6 on a stable, low-variance series and +6 on a volatile one describe completely different situations. The first is a substantial departure from normal. The second may be entirely unremarkable.

This is a direct consequence of the transform. Dividing by the trailing mean centres the series but does nothing to standardise its dispersion. You have removed differences in level between series. You have not removed differences in scale of variation.

The result is a measure that is well-suited to answering “is this series unusually strong relative to its own history?” and poorly suited to answering “which of these twenty series is strongest?” Using it for the second question is a common and quiet error.

If you need cross-sectional comparability

The fix is the obvious one: divide by a dispersion measure as well as centring.

def relative_strength_z(asset: pd.Series,                         benchmark: pd.Series,                         n: int = 52) -> pd.Series:     “””Rolling z-score of the asset/benchmark ratio.”””     rp = (asset / benchmark) * 100     roll = rp.rolling(n)     return (rp – roll.mean()) / roll.std()

This is a rolling z-score, and it does give you cross-sectional comparability, at a cost. The units become standard deviations rather than percentages, which is less intuitive to read. It is unstable when trailing variance is near zero. And it is more sensitive to the choice of window, since you are now estimating a second moment rather than a first.

The original formulation makes a deliberate trade: it sacrifices cross-series comparability to keep the output in percentage terms and to stay robust with a short history. Whether that is the right trade depends entirely on which question you are asking.

That is the general lesson. Normalisation is not a single operation with a correct answer — centring and scaling are separable decisions, and which you apply determines which comparisons remain valid downstream.

Implementation notes

Window length. The 52 and 200 period conventions come from wanting roughly a year of context. Shorter windows produce a noisier series that crosses zero frequently. There is no theoretically correct value.

Benchmark choice. The transform is only as meaningful as the benchmark. Comparing a small-cap series against a large-cap index measures the size factor as much as anything specific to the asset.

Missing data. Both series need aligned indices. pandas will align on the join, but a benchmark with different trading days will silently produce NaNs that propagate through the rolling window.

Look-ahead. rolling().mean() is trailing by default in pandas, which is what you want. If you centre the window you introduce look-ahead bias, which will make any backtest look excellent and be worthless.

Where this comes from

The construction dates to Stan Weinstein’s 1988 book on stage-based trend analysis, where it appears as a relative strength measure. The name attached to it — Mansfield — is not a person. The charts reproduced in that book came from the Mansfield chart service, and the line printed on them took the name of the service that printed it. The chart service is long gone; the name stuck.

It remains in use in market analysis platforms today. Mansfield Relative Strength is implemented in TradeVision’s charting alongside conventional indicators, benchmarked to the S&P 500 by default, which is a reasonable place to see the output plotted if you would rather not implement it before deciding whether it is useful. The technique itself is domain-agnostic. Any time you need to compare a series against a reference while keeping the result interpretable over time, the same two steps apply, and the same caveat about cross-series comparison applies with them.