Insights
How to Backtest a Momentum Strategy in Python
Alphanume Team · June 5, 2026
Ranking, rebalancing, and turnover, in code.
A momentum backtest python practitioners actually trust comes down to three disciplines: computing the signal correctly, lagging it before you act, and accounting for the cost of turning over the portfolio. Get any one wrong and the backtest flatters a strategy that would have lost money in live trading. This post builds a compact, correct skeleton using pandas — one you can swap your own universe into without rewriting the core logic. If you want the conceptual background before diving into the code, the guide on how to build a momentum strategy is a good place to start.
The data assumption
The code below expects a wide DataFrame of monthly prices where the index is a DatetimeIndex of month-end dates and each column is a ticker. Call it prices. In practice, you would derive this from a daily price DataFrame by resampling, or pull it directly from a point-in-time data source — the Alphanume API can supply both. The important constraint is that the universe must not be survivorship-filtered: any ticker that was alive on a given date belongs in the cross-section for that date, even if it was delisted later. Dropping ex-post losers is how most informal backtests silently overstate returns.
import pandas as pd
import numpy as np
# prices: DatetimeIndex (month-end), columns = tickers, values = adjusted close
# Derive from a daily wide DataFrame:
daily_prices = pd.DataFrame(...) # your wide daily price frame
prices = daily_prices.resample("ME").last()
The resample("ME").last() call picks the last available price in each calendar month. If your daily frame already has month-end rows, you can skip the resample. Either way, keep all columns — including names with NaN gaps — until the signal-formation step.
Computing the 12-1 momentum signal
The standard academic signal is the trailing 12-month total return with the most recent month excluded. The exclusion exists because of well-documented short-term reversal: including the last month actually weakens the signal in many markets. In pandas, "trailing 12 months skipping the last month" is a one-liner once you understand the indexing.
# 12-1 momentum: return from t-12 to t-1
# shift(1) moves prices forward one period so row t holds the price from t-1
# pct_change(12) then computes the 12-month return ending at that lagged price
mom = prices.shift(1).pct_change(12)
Read this left to right: shift(1) slides the entire price matrix forward by one month, so the value you see on row t is the price from t-1. Calling pct_change(12) on that lagged frame gives the return from t-13 to t-1 — exactly the 12-1 signal. The result is a DataFrame of the same shape as prices, with NaN wherever there is insufficient history.
Ranking and forming the portfolio
At each month-end, rank the cross-section and assign names to deciles. The top decile goes long; the bottom decile can be shorted if you want a long-short factor. Ranking row-by-row is what axis=1 is for, and pct_rank normalises the output to the [0, 1] range regardless of how many names are in the universe that month.
ranks = mom.rank(axis=1, pct=True)
# Long the top decile (rank >= 0.9)
long_mask = ranks >= 0.9
# Equal-weight within the long book each month
n_long = long_mask.sum(axis=1).replace(0, np.nan)
long_weights = long_mask.div(n_long, axis=0)
# Monthly returns of each ticker (one-period ahead — NOT yet lagged)
ret = prices.pct_change()
# Portfolio return: signal from t, applied to ret at t+1
# shift(1) on weights ensures we use t-end weights to earn t+1 returns
port_ret = (long_weights.shift(1) * ret).sum(axis=1)
equity_curve = (1 + port_ret).cumprod()
The shift(1) on long_weights is the critical look-ahead guard. Without it, the weight on row t would be formed from the signal also on row t and applied to the return also on row t — which requires knowing month-end prices before the month ends. Shifting the weights forward ensures you only trade on information that was available when the month closed.
Modeling turnover and transaction costs
An equity curve built from gross returns looks better than what you would actually earn. Momentum strategies rotate significantly — turnover of 20–40% per month is common — and those round-trips carry bid-ask spread and market-impact costs even in liquid names. A simple but honest approach is to subtract a flat cost per unit of turnover on each rebalance date.
ONE_WAY_COST = 0.0010 # 10 bps per side; adjust to your market
# Turnover: absolute change in weights between consecutive rebalances
turnover = long_weights.diff().abs().sum(axis=1)
# Cost drag: one-way cost applied to each dollar traded
cost_drag = turnover * ONE_WAY_COST
# Net portfolio return after costs
port_ret_net = port_ret - cost_drag.shift(1)
equity_curve_net = (1 + port_ret_net).cumprod()
The shift(1) on cost_drag aligns the cost with the period in which the trades settle. One basis point per side is a conservative assumption for large-cap US equities in recent years; emerging markets, small caps, or earlier time periods may warrant 5–20 bps. The important discipline is that you pick a number and apply it consistently — not that you optimise it after seeing the results.
What the equity curve tells you — and what it does not
The .cumprod() equity curve is a useful sanity check. If it trends in the right direction and the Sharpe ratio looks plausible, the plumbing is probably correct. If it looks implausibly smooth or the returns have zero drawdown, something is still leaking the future. Common culprits are a forgotten shift, a fillna that replaces genuine missing data with zero returns, or a universe that was filtered ex-post.
What the curve cannot tell you is whether the strategy will work going forward, how it behaves across different rate regimes, or whether the parameters (12-1, top decile, 10 bps cost) are the right ones. This skeleton is a teaching tool, not a tuned strategy — the right next step is stress-testing across lookback windows, holding periods, and cost assumptions before reading too much into any single equity curve. For a live example of a rules-based momentum portfolio built on these principles, see the Quant Galore Momentum Index.
Vectorized versus event-driven execution
The entire skeleton above is vectorized: it computes all dates simultaneously in pandas rather than stepping through a calendar one bar at a time. Vectorized backtests are fast to write and easy to read, but they have a structural weakness — it is easy to accidentally mix future and present data in a single matrix operation, and the mistake can be invisible in the output. An event-driven engine processes each bar in order and makes accidental look-ahead much harder. For a longer treatment of the trade-offs, the post on vectorized vs event-driven backtesting walks through both approaches with examples. For a strategy as straightforward as monthly-rebalance momentum, the vectorized approach is usually the right call — as long as every shift is in place.
Putting it together
The full skeleton fits in fewer than thirty lines of pandas. The signal computation, the ranking, the weight construction, the look-ahead guard, and the cost drag are each a single statement. That compactness is not just aesthetic — a short pipeline is easier to audit line by line, which is the only reliable way to convince yourself the backtest is clean. Before you extend the skeleton with position limits, sector neutralization, or a short book, verify the gross and net equity curves behave sensibly on a universe you understand well. A sanity-check on a well-studied benchmark period, where you already know roughly what momentum delivered, is a more useful correctness test than any unit test you could write.