Case Study - Building a Quantitative Strategy from Scratch › Stage 4: Backtesting with Transaction Costs

Michael BrenndoerferJanuary 25, 20266 min read
Quantitative FinanceAll chapters
Part 14 of 15Strategy Development and Backtesting
Chapter 76Case Study - Building a Quantitative Strategy from Scratch › Stage 4: Backtesting with Transaction Costs
Reading section 5 of 12 · Stage 4: Backtesting with Transaction Costs · 6 minSection 5 of 12 · 6 min

Book contents

Section 5 of 12 · 6 min read

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 st=PB,t−βPA,t−αs_t = P_{B,t} - \beta P_{A,t} - \alpha. If we hold a long spread position, we own Stock B and are short β\beta 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 β\beta 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:

PnLt=post−1⋅Δst\text{PnL}_t = \text{pos}_{t-1} \cdot \Delta s_t

where:

  • PnLt\text{PnL}_t: profit or loss at time tt per unit of spread
  • post−1\text{pos}_{t-1}: position held from t−1t-1 to tt (+1 long, -1 short, 0 flat), determined by the signal at t−1t-1
  • Δst\Delta s_t: change in the spread from t−1t-1 to tt (st−st−1s_t - s_{t-1}), 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 t−1t-1 is held overnight and exposed to the spread change that occurs on day tt. 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.

In[13]:
Code
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 results

In-Sample Backtest

We first verify the strategy works on training data. This is not our final evaluation, but a sanity check.

In[14]:
Code
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)
Out[15]:
Console
=== 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.

In[16]:
Code
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[17]:
Console
=== 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.

Out[18]:
Visualization
A blue in-sample cumulative PnL line rises from zero to about 450 thousand dollars by late 2021; the amber out-of-sample continuation peaks near 650 thousand before ending near 560 thousand in 2023.
Cumulative strategy PnL across the in-sample and out-of-sample periods. PnL rises in both segments, but the out-of-sample curve finishes below its late-period peak; the dashed line marks the train-test boundary.

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.