Skip to content
GetProfitable
Search

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) from position and 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.

How a position size is worked outAccount size, risk per trade and stop distance feed into one box giving the number of shares.ACCOUNT SIZE$25,000your capitalRISK PER TRADE1%of the accountSTOP DISTANCE$0.50entry to stopPOSITION SIZE500 sharesrisk budget: $25,000 × 1% = $250position size: $250 ÷ $0.50 = 500 shares
Working out a position size. Three numbers decide how big a trade is: the account, the share of it put at risk, and the distance from entry to stop. One percent of $25,000 is a $250 budget, and a $0.50 stop divides into that 500 times.
A fast and a slow moving average crossingA jagged price line with two smoother average lines through it; the fast average dips below the slow one on the left and cuts back above it in the middle, where a circle marks the crossing.pricefast averageslow averagefast crosses belowfast crosses abovethe slow averageAverages of recent closes; the fast one reacts sooner than the slow one.
Fast and slow moving averages crossing. A moving average is the average of the last few closing prices, redrawn each period. An average over fewer periods turns sooner than one over many, so the two lines cross whenever the recent pace of the market changes.