Case Study - Building a Quantitative Strategy from Scratch › Stage 4: Backtesting with Transaction Costs
Quantitative FinanceAll chapters Case Study - Building a Quantitative Strategy from Scratch › Stage 4: Backtesting with Transaction CostsOne section · Sections in this chapter5 / 12 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Now we run a proper backtest. As we covered extensively in the backtesting chapter, the key principles are:
- No lookahead bias. We use only information available at the time of each decision.
- Realistic transaction costs. We model both commissions and market impact.
- Out-of-sample evaluation. Final performance is assessed only on data not used for model estimation.
Strategy Returns Calculation
The spread return on a pairs trade is the dollar profit/loss on the spread position. When we are long the spread (long B, short A), we profit when B rises relative to A weighted by the hedge ratio. To understand why, recall that the spread is defined as . If we hold a long spread position, we own Stock B and are short units of Stock A. When the spread increases, it means that Stock B has appreciated relative to the hedge-ratio-weighted value of Stock A, generating a profit on our combined position. Conversely, when we are short the spread, we are short Stock B and long units of Stock A, and we profit when the spread decreases.
Specifically, the daily P&L per unit of spread captures the gain or loss from spread movements relative to our position:
where:
- : profit or loss at time per unit of spread
- : position held from to (+1 long, -1 short, 0 flat), determined by the signal at
- : change in the spread from to (), representing the market move
This formula encodes a fundamental feature of any trading strategy: the position must be determined before the market moves that generate the P&L. The position at the end of day is held overnight and exposed to the spread change that occurs on day . This temporal ordering is what prevents lookahead bias: we never use tomorrow's information to determine today's position.
To express returns as a fraction of capital, we normalize by the average absolute spread level over the lookback window, which represents the capital at risk per unit of spread. This normalization is necessary because dollar P&L alone does not tell us whether the strategy is efficient with capital. A strategy that earns $100 per day while tying up $1 million is far less attractive than one that earns $100 while risking $10,000. By scaling the P&L relative to the notional capital deployed, we obtain a return measure that is comparable across time periods and across strategies of different sizes.
def backtest_strategy(
df,
beta,
intercept,
lookback,
z_entry,
z_exit,
z_stop,
cost_bps=10,
initial_capital=1_000_000,
):
"""
Run pairs trading backtest with transaction costs.
Parameters:
cost_bps: round-trip transaction cost in basis points
initial_capital: starting capital in dollars
"""
spread, zscore = compute_spread_and_zscore(
df["Stock_A"], df["Stock_B"], beta, intercept, lookback
)
positions = generate_signals(zscore, z_entry, z_exit, z_stop)
# Daily spread changes
spread_change = spread.diff()
# Strategy daily PnL (per unit of spread)
raw_pnl = (
positions[:-1] * spread_change.values[1:]
) # position at t determines PnL at t+1
raw_pnl = np.insert(raw_pnl, 0, 0) # no PnL on first day
# Transaction costs: incurred when position changes
position_changes = np.abs(np.diff(positions))
position_changes = np.insert(position_changes, 0, abs(positions[0]))
# Cost = notional value * cost_bps / 10000
# Approximate notional as price_A + beta * price_B per unit
notional_per_unit = df["Stock_A"].values + abs(beta) * df["Stock_B"].values
costs = position_changes * notional_per_unit * (cost_bps / 10_000)
# Net PnL per unit
net_unit_pnl = raw_pnl - costs
# Position Sizing: Risk 2% of capital per trade
# Use rolling spread std to size positions inversely to volatility
# We shift by 1 to ensure no lookahead bias in sizing
spread_std = spread.rolling(lookback).std()
capital_at_risk = initial_capital * 0.02
# Number of units to hold
position_units = (capital_at_risk / spread_std).shift(1).fillna(0)
# Total Portfolio PnL ($)
total_pnl = net_unit_pnl * position_units.values
total_costs = costs * position_units.values
# Compute cumulative returns
cumulative_pnl = np.cumsum(total_pnl)
results = pd.DataFrame(
{
"spread": spread,
"zscore": zscore,
"position": positions,
"units": position_units,
"raw_pnl": raw_pnl * position_units.values,
"costs": total_costs,
"net_pnl": total_pnl,
"cumulative_pnl": cumulative_pnl,
"notional": notional_per_unit * position_units.values,
},
index=df.index,
)
return resultsIn-Sample Backtest
We first verify the strategy works on training data. This is not our final evaluation, but a sanity check.
results_train = backtest_strategy(
df_train,
beta_hat,
intercept_hat,
lookback,
z_entry,
z_exit,
z_stop,
cost_bps=10,
)
n_trades_train = int(
np.sum(np.abs(np.diff(results_train["position"].values)) > 0)
)
total_pnl_train = results_train["net_pnl"].sum()
total_costs_train = results_train["costs"].sum()
win_rate_train = np.mean(
results_train.loc[results_train["net_pnl"] != 0, "net_pnl"] > 0
)
cost_pct_train = 100 * total_costs_train / (total_pnl_train + total_costs_train)=== In-Sample Backtest Results === Total net PnL: $453,647.99 Total costs: $48,220.53 Cost as % of PnL: 9.6% Number of trades: 56 Win rate (daily): 50.8%
The in-sample results confirm the strategy mechanics are working. We see a high daily win rate and a reasonable number of trades (roughly one every 2-3 weeks). Transaction costs consume a manageable fraction of gross PnL, validating that the 2-standard-deviation entry threshold isn't generating excessive churn.
Out-of-Sample Backtest
Now we run the same strategy on the out-of-sample period using the hedge ratio and intercept estimated from the training period. This is the true test.
results_test = backtest_strategy(
df_test,
beta_hat,
intercept_hat,
lookback,
z_entry,
z_exit,
z_stop,
cost_bps=10,
)
n_trades_test = int(
np.sum(np.abs(np.diff(results_test["position"].values)) > 0)
)
total_pnl_test = results_test["net_pnl"].sum()
total_costs_test = results_test["costs"].sum()
trading_days_test = len(results_test)=== Out-of-Sample Backtest Results === Total net PnL: $110,516.03 Total costs: $27,690.85 Number of trades: 23 Trading days: 500
The strategy remains profitable on unseen data, generating a positive net profit over the 500-day test period. The number of trades scales proportionally with the time period, suggesting the signal opportunities are structural rather than artifacts of the training set.

The cumulative PnL chart shows gains in both periods, but the path is uneven. In particular, the out-of-sample segment gives back part of its late-period peak, which motivates examining drawdowns rather than relying on the endpoint alone.