◆ New — Strategy Validator, Code Fixer & Prop Compliance Checker are live. Try one free →
Home / Learn / pandas .shift() for Trading Signals: +1 vs -1 (and Why It Matters)
Python

pandas .shift() for Trading Signals: +1 vs -1 (and Why It Matters)

In pandas, .shift(1) moves values from the past forward in time (safe for signals) while .shift(-1) pulls values from the future backward (a look-ahead bug). Getting the sign wrong is the single most common way a Python backtest silently cheats. This article walks through the mechanics, worked examples, and a mental model that keeps you on the safe side. It is educational content about backtest correctness only, not trading or financial advice, and makes no claim about returns.

What does .shift(1) versus .shift(-1) actually do?

In pandas, series.shift(1) moves every value down to a later index position, so the value that was at time t-1 now sits at time t. In plain terms, .shift(1) brings the past forward. By contrast, series.shift(-1) moves every value up to an earlier index position, so the value that was at time t+1 now sits at time t. That means .shift(-1) brings the future backward, and for trading signals that is almost always a bug.

The reason this matters so much is timing. A backtest is only honest if every decision on bar t uses information available at or before t. When you write signal = raw.shift(1), the signal you act on at bar t is whatever you computed from data through bar t-1, which is exactly what you would have known in real time. When you write signal = raw.shift(-1), the signal at bar t is computed from bar t+1, which you could not possibly have known yet. The backtest then trades on tomorrow's information today.

This is the concrete, line-level face of [look-ahead bias](/learn/look-ahead-bias). It is worth internalizing the direction as a fixed rule: positive shift = safe = past forward; negative shift = dangerous = future backward. Everything else in this article follows from that one distinction.

A worked example of the safe direction

Suppose you compute a raw signal from each bar's close and want to trade on it. The rule is: decide using the close of bar t, act on bar t+1. That means the signal you hold on bar t+1 should be the one you computed on bar t, which is exactly what .shift(1) produces.

# ✅ CORRECT: decide on bar t, act on bar t+1
import pandas as pd

fast = df["close"].rolling(10).mean()
slow = df["close"].rolling(30).mean()

# raw position: 1 when fast above slow, else 0 (computed from bar t)
raw_position = (fast > slow).astype(int)

# hold tomorrow the position you decided today; first bar has no prior decision
position = raw_position.shift(1).fillna(0)

# next-bar return, applied to the position you actually held
ret = df["close"].pct_change()
strategy_ret = position * ret

Walk through the alignment. raw_position[t] is computed from close[t]. After .shift(1), position[t] equals raw_position[t-1], so on bar t you hold the decision you made on bar t-1. Multiplying by ret[t] (the return into bar t) means you earn the move from t-1 to t only if you were already positioned by the end of t-1. No bar ever uses its own future.

The .fillna(0) matters: the first bar has no prior decision, so the position must be flat rather than NaN. Leaving NaN there can quietly propagate and mask errors. This is the boring, correct backbone that the [Python Backtest Validator](/python) is checking for when it scans your signal pipeline.

The look-ahead version (clearly wrong)

Here is the same idea with the sign flipped. It looks almost identical and is easy to write by accident, especially when you are trying to align a signal to "the return it predicts."

# ❌ WRONG: position at bar t is computed from bar t+1
import pandas as pd

raw_position = (fast > slow).astype(int)

# pulls the FUTURE decision backward onto bar t
position = raw_position.shift(-1)

ret = df["close"].pct_change()
strategy_ret = position * ret   # trades on information from the next bar

After .shift(-1), position[t] equals raw_position[t+1], which was computed from close[t+1]. So on bar t you are holding a position that depends on a price you have not seen yet. The equity curve this produces is often startlingly smooth, because the strategy is effectively told the answer one bar early. That smoothness is the seduction: it reads as skill and is actually leakage.

A closely related trap is .shift(-1) applied to the return instead of the signal, for example future_ret = ret.shift(-1) and then trading on sign(future_ret). Same disease, different limb: you are correlating today's action with tomorrow's realized move. If you catch either pattern in a review, treat the whole backtest's numbers as unusable until it is fixed. The fix is always to move the negative shift out of the decision path.

The one legitimate use of a negative shift

There is exactly one place a negative shift is acceptable, and it is not in the live signal. When you are building a supervised-learning label for research, the target you are trying to predict is, by definition, a future value. Constructing that label with .shift(-1) is correct precisely because the label is never fed back in as a feature.

# ✅ CORRECT use of a negative shift: building a training LABEL only
import pandas as pd

ret = df["close"].pct_change()

# label = next bar's return direction; this is the thing we predict, not a feature
label = (ret.shift(-1) > 0).astype(int)

# features must use ONLY past/current data
features = pd.DataFrame({
    "mom_10": df["close"].pct_change(10),
    "vol_20": ret.rolling(20).std(),
})

# drop the final rows where the label is undefined (no future bar exists yet)
valid = features.dropna().index.intersection(label.dropna().index)
X, y = features.loc[valid], label.loc[valid]

The discipline here is a hard wall between features and label. Features may only use .shift(0) or positive shifts and trailing windows; the label may use .shift(-1). The instant a shifted-future column leaks into X, you have target leakage, which is look-ahead wearing a machine-learning costume. And critically, any normalization (means, maxima, standard deviations) must be fit on the training slice only, never on the whole series, or you leak future distribution information a different way. That train/test discipline is spelled out in [in-sample vs out-of-sample](/learn/in-sample-vs-out-of-sample) and [walk-forward analysis](/learn/walk-forward-analysis).

A mental model that keeps you safe

Reduce the whole topic to one sentence: positive shift brings the past forward and is safe; negative shift brings the future backward and belongs only in a label, never in a live signal. If you tattoo that on your build process, you eliminate the most common Python backtest bug there is.

A few habits make it stick. First, always .fillna(0) or .fillna(False) after shifting a position, so the un-decided first bar is flat rather than silently NaN. Second, when in doubt, print a tiny slice: put raw_position, position, and ret side by side for five rows and confirm with your eyes that position[t] never depends on ret[t]'s own bar. Third, run a delay-sensitivity check: if adding one more bar of positive shift barely changes results, your timing is robust; if it collapses, you were leaning on leaked information.

These line-level habits pair with the structural checks. Even a perfectly-shifted signal can still be overfit or fail out of sample, which is why [why most backtests fail](/learn/why-most-backtests-fail), [overfitting and curve-fitting](/learn/overfitting-curve-fitting-explained), and the [overfitting checker](/backtest/overfitting) exist as separate lines of defense. And you can have the sign checked automatically: the [Python Backtest Validator](/python) flags negative shifts on signals and whole-series statistics before they reach production. This article is educational and about correctness only. It is not financial advice and makes no claim about profitability or returns.

Key takeaways

Educational only — not financial advice. Trading involves substantial risk of loss.

Catch the bug that compiles.Run auditGet Pro