Case Study - Building a Quantitative Strategy from Scratch › Stage 3: Signal Construction

Michael BrenndoerferJanuary 25, 20269 min read
Quantitative FinanceAll chapters
Part 14 of 15Strategy Development and Backtesting
Chapter 76Case Study - Building a Quantitative Strategy from Scratch › Stage 3: Signal Construction
Reading section 4 of 12 · Stage 3: Signal Construction · 9 minSection 4 of 12 · 9 min

Book contents

Section 4 of 12 · 9 min read

Case Study - Building a Quantitative Strategy from Scratch › Stage 3: Signal ConstructionOne section · Sections in this chapter4 / 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

With a confirmed cointegrated pair and an estimated hedge ratio, we now construct our trading signal. The signal is based on the z-score of the spread, which normalizes the spread by its rolling mean and standard deviation. The fundamental challenge in signal construction is translating the statistical observation that "the spread is mean-reverting" into a concrete, actionable rule that tells us when to enter and exit positions. Raw spread values are not directly useful for this purpose because the spread's volatility can change over time: a deviation of five dollars might be entirely normal during a volatile month but represent an extreme outlier during a calm one. We need a way to measure deviations in context, relative to recent behavior, and the z-score provides exactly that.

Z-Score Signal

The z-score normalizes the spread by its recent volatility to identify statistically significant deviations. The intuition is borrowed from elementary statistics: just as a z-score in a hypothesis test tells you how many standard deviations an observation lies from the expected value, our trading z-score tells us how many standard deviations the current spread lies from its recent average. A z-score of +2, for instance, means the spread is two standard deviations above its rolling mean, indicating that Stock B is unusually expensive relative to Stock A, given recent behavior. This normalization makes the signal comparable across time and across different pairs with different spread scales.

At time tt, the z-score is defined as:

zt=st−sˉt(w)σt(w)z_t = \frac{s_t - \bar{s}_t^{(w)}}{\sigma_t^{(w)}}

where:

  • ztz_t: standardized score of the spread at time tt, indicating how many standard deviations the spread is from its mean
  • sts_t: spread value at time tt
  • sˉt(w)\bar{s}_t^{(w)}: rolling mean of the spread over lookback window ww, representing the moving equilibrium level
  • σt(w)\sigma_t^{(w)}: rolling standard deviation of the spread, adjusting the signal for changing market volatility
  • ww: length of the lookback window (e.g., 60 days)

Notice that both the rolling mean and the rolling standard deviation use only past data, computed over the window from t−w+1t - w + 1 to tt. This is essential for avoiding lookahead bias: at every point in time, the z-score is based exclusively on information that would have been available to you when making the decision in real time. The rolling nature of these statistics also allows the signal to adapt to slowly changing market conditions, such as a gradual shift in the spread's equilibrium level or a period of high volatility, without requiring explicit regime detection.

The trading rules are simple and follow directly from the mean-reversion logic:

  • Enter long spread (buy B, sell A) when zt<−zentryz_t < -z_{\text{entry}}: the spread is unusually low, so we expect it to rise.
  • Enter short spread (sell B, buy A) when zt>zentryz_t > z_{\text{entry}}: the spread is unusually high, so we expect it to fall.
  • Exit when ztz_t crosses back through ±zexit\pm z_{\text{exit}}: the spread has reverted enough to take profits.
  • Stop-loss when ∣zt∣>zstop|z_t| > z_{\text{stop}}: the spread has moved further against us, suggesting the relationship may have broken down.

The asymmetry between the entry and exit thresholds is intentional and economically motivated. We require a large move (2 standard deviations) to open a position because such a move is less likely to reflect normal fluctuation. But we exit at a much smaller threshold (0.5 standard deviations) because waiting for the spread to return all the way to zero would expose us to unnecessary holding-period risk. In a mean-reverting process, the bulk of the reversion happens quickly, and the last fraction of the move can take disproportionately long. Capturing 75% of the reversion quickly is more attractive on a risk-adjusted basis than capturing 100% slowly. The stop-loss limits downside risk rather than capturing profit. If the z-score expands to 3.5, it may signal that the cointegrating relationship has broken down entirely, and continued mean-reversion bets would be betting against a structural shift.

Parameter Selection

We must choose several parameters. Rather than optimizing these on the training set (which risks overfitting), we select them based on economic reasoning and the stylized facts of mean-reverting processes. This distinction is critical. If we had scanned hundreds of parameter combinations and selected the one that produced the highest backtest return, we would be fitting to noise in the training data, and the out-of-sample performance would almost certainly disappoint. Instead, each parameter choice is justified on first principles:

  • Lookback window (ww): 60 trading days (~3 months). This is long enough to estimate a stable mean and standard deviation, but short enough to adapt to regime changes. A window that is too short (say, 10 days) would produce noisy estimates and generate excessive false signals. A window that is too long (say, 250 days) would be slow to adapt and would dilute recent deviations with stale history.
  • Entry threshold (zentryz_{\text{entry}}): 2.0. This corresponds to a roughly 2-standard-deviation move, which balances trade frequency against signal quality. Under a normal distribution, roughly 5% of observations fall beyond two standard deviations, so this threshold ensures we are only trading under unusual conditions. A lower threshold would generate more trades but with weaker expected reversion, while a higher threshold would produce stronger signals but too few trades to build a meaningful track record.
  • Exit threshold (zexitz_{\text{exit}}): 0.5. We don't wait for full reversion to zero; capturing the bulk of the move reduces holding-period risk. As noted above, the final portion of mean reversion is the slowest and most uncertain.
  • Stop-loss threshold (zstopz_{\text{stop}}): 3.5. This protects us from structural breaks in the relationship. A z-score of 3.5 is an extremely rare event under the null of a stationary spread, suggesting that our model assumptions may no longer hold.
In[9]:
Code
# Strategy parameters
lookback = 60
z_entry = 2.0
z_exit = 0.5
z_stop = 3.5

Building the Signal Function

We encapsulate the signal logic in a function that can be applied to any dataset, ensuring consistency between in-sample analysis and out-of-sample testing. This modular design simplifies the software and guards against a subtle form of bias. If the signal computation were written differently for the training and test sets, even unintentionally, the results would not be comparable. By funneling both periods through the same function with the same parameters, we ensure that any difference in performance reflects out-of-sample behavior rather than implementation inconsistency.

In[10]:
Code
def compute_spread_and_zscore(prices_a, prices_b, beta, intercept, lookback):
    """Compute spread and rolling z-score for a pairs trading strategy."""
    spread = prices_b - (beta * prices_a + intercept)
    roll_mean = spread.rolling(window=lookback).mean()
    roll_std = spread.rolling(window=lookback).std()
    zscore = (spread - roll_mean) / roll_std
    return spread, zscore


def generate_signals(zscore, z_entry, z_exit, z_stop):
    """
    Generate trading signals from z-score.
    Returns position series: +1 (long spread), -1 (short spread), 0 (flat).
    """
    position = np.zeros(len(zscore))

    for t in range(1, len(zscore)):
        z = zscore.iloc[t] if hasattr(zscore, "iloc") else zscore[t]

        if np.isnan(z):
            position[t] = 0
            continue

        prev_pos = position[t - 1]

        # Stop-loss: exit if spread moves too far
        if prev_pos != 0 and abs(z) > z_stop:
            position[t] = 0
            continue

        # Exit logic: close position when z-score reverts
        if prev_pos == 1 and z >= -z_exit:
            position[t] = 0
        elif prev_pos == -1 and z <= z_exit:
            position[t] = 0
        # Entry logic: open position on extreme z-scores
        elif prev_pos == 0 and z < -z_entry:
            position[t] = 1  # Long spread: buy B, sell A
        elif prev_pos == 0 and z > z_entry:
            position[t] = -1  # Short spread: sell B, buy A
        else:
            position[t] = prev_pos  # Hold current position

    return position

Let's apply these functions to the training data to see how the signal behaves in-sample.

In[11]:
Code
# Compute spread and z-score on training data
spread_train_series, zscore_train = compute_spread_and_zscore(
    df_train["Stock_A"], df_train["Stock_B"], beta_hat, intercept_hat, lookback
)

# Generate signals
positions_train = generate_signals(zscore_train, z_entry, z_exit, z_stop)
Out[12]:
Visualization
In-sample spread z-score from 2018 through 2021 with paired dashed entry lines at plus and minus 2, exit lines at plus and minus 0.5, and a zero baseline.
Spread z-score relative to trading thresholds over the training period. The signal oscillates within the defined bands, with breaches of the ±2.0 entry thresholds marking significant deviations from the mean.
Step-like position areas from 2018 through 2021 alternate among long at plus 1, flat at zero, and short at minus 1.
Trading positions derived from the z-score rules. Repeated long (+1) and short (-1) holding intervals show when the strategy has exposure; flat intervals remain at zero.

The signals look sensible: the strategy enters when the z-score hits extreme values and exits when it reverts toward zero. We can also see that the stop-loss threshold is rarely triggered, which is expected in a well-behaved cointegrated pair.