Signal, position, returns
Lesson 11 · about 12 min
Three columns turn a rule into a number: the signal (what the rule wants), the position (what you actually hold on each bar), and the strategy return (what that position earned). They are three different things, and most backtest bugs are one of them pretending to be another. This lesson builds the chain one column at a time and shows the check that proves each link.
From signal to position
The signal at bar t is computed from the close of bar t. You cannot have acted on it during bar t, because bar t had not finished. The earliest you can hold the position the signal asks for is bar t + 1. So:
import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv
from src.indicators import sma
def ma_crossover_signal(close: pd.Series, fast: int = 20, slow: int = 50) -> pd.Series:
f, s = sma(close, fast), sma(close, slow)
signal = (f > s).astype(float)
signal[s.isna()] = np.nan
return signal.rename("signal")
bars = synthetic_ohlcv(750, seed=42)
signal = ma_crossover_signal(bars["close"])
position = signal.shift(1).rename("position")
position[t] = signal[t - 1]. That one shift(1) is the difference between a backtest and a fantasy. Without it, the position on bar t would be decided by bar t's own close, which means the strategy buys at the start of every up day and is flat on every down day. Module 5 goes deeper; for now, make the shift a reflex.
For a long-only strategy the position is 1 or 0 units of exposure. Units of what? For this module, "fully invested" means the whole account is in the instrument when the position is 1. Sizing by risk comes in Module 5.
From position to returns
If you hold position p (as a fraction of the account) over bar t, and the instrument returns r[t] over that bar, you earn p × r[t].
asset_ret = bars["close"].pct_change().rename("asset_ret")
strat_ret = (position * asset_ret).rename("strat_ret")
frame = pd.concat([bars["close"], signal, position, asset_ret, strat_ret], axis=1)
print(frame.iloc[48:56].round(4))
Look at rows around the first signal change. On the bar where signal first becomes 1, position is still 0 (or NaN), and strat_ret is 0. On the next bar, position is 1 and strat_ret equals asset_ret. That visual check, every time, for every new strategy.
The close-to-close return is the right one here because the position is assumed to be entered at the close of bar t - 1 and held to the close of bar t. If you instead enter at the open of bar t, you would use close[t] / open[t] - 1 for the entry bar. Module 5 handles that variant.
Compounding into equity
equity = (1 + strat_ret.fillna(0)).cumprod().rename("equity")
benchmark = (1 + asset_ret.fillna(0)).cumprod().rename("buy_and_hold")
print(f"strategy final equity: {equity.iloc[-1]:.3f}")
print(f"buy and hold: {benchmark.iloc[-1]:.3f}")
fillna(0) is correct here: a NaN return during warm-up means "no position", which earns zero. The cumprod compounds simple returns. Starting equity is 1.0, so the final value is a growth multiple; multiply by a starting balance if you want dollars.
Do not read anything into which number is bigger. This is one synthetic random walk with a positive drift; a trend-following rule on it will sometimes beat buy-and-hold and sometimes not, and the difference tells you nothing about markets. What you are checking is that the arithmetic is right.
The three checks
Before trusting any backtest built this way, run these:
# 1. Position is never decided by the same bar's close.
assert position.equals(signal.shift(1))
# 2. Strategy returns are zero whenever flat.
assert (strat_ret[position == 0].abs() < 1e-15).all()
# 3. When fully invested, strategy return equals asset return.
invested = position == 1
assert np.allclose(strat_ret[invested], asset_ret[invested])
They read as trivial. They are, and they catch the majority of real bugs: a signal accidentally computed from a forward-shifted column, a position column that was never shifted, a return series with a different index that silently aligned to NaN.
Key idea: signal[t] is computed from bar t; position[t] = signal[t − 1]; strat_ret[t] = position[t] × asset_ret[t]. Three columns, one shift, and a check on each link.
Turnover: how much you trade
Every change in position is a trade, and trades cost money (Module 5). Measure it now so the cost model has something to bite on.
turnover = position.diff().abs().fillna(0).rename("turnover")
print(f"total turnover (units traded): {turnover.sum():.0f}")
print(f"trades per year: {turnover.sum() / (len(bars) / 252):.1f}")
For a long-only strategy, turnover of 1 is either an entry or an exit; a round trip is 2. For a long/short strategy that flips from +1 to −1, a single flip is turnover 2, because you sell the long and sell short again.
Long/short in the same frame
Nothing above assumed long-only. If signal takes values in {−1, 0, +1}, position × asset_ret handles shorts automatically: a −1 position on a −2% day earns +2%. What it does not handle is the cost of borrowing to short, margin interest, or the fact that some instruments cannot be shorted at all. Those are realism questions, and the answer for now is: a −1 position in a vectorized backtest is an approximation that is fine for futures and forex, roughly fine for liquid large-cap stocks, and wrong for small caps and many crypto spot venues.
ls_signal = signal.replace(0.0, -1.0) # flat becomes short
ls_position = ls_signal.shift(1)
ls_equity = (1 + (ls_position * asset_ret).fillna(0)).cumprod()
print(f"long/short final equity: {ls_equity.iloc[-1]:.3f}")
Try it: Remove the
shift(1)frompositionand recompute the final equity. Write down the number. It will be dramatically better, and it is a lie. Then shift by 2 instead of 1 (acting one bar late) and note how much the result degrades; that gap is a first estimate of how sensitive the rule is to execution delay.
Recap
- Signal is what the rule wants at bar t; position is what you hold over bar t; strategy return is position × asset return.
- position = signal.shift(1); no exceptions in a close-to-close backtest.
- Equity compounds simple returns with
cumprod; fill warm-up NaNs with zero return. - Assert the three links: shift applied, zero return when flat, asset return when invested.
- Turnover is
position.diff().abs(); it is what costs will be charged on.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.