Chapter 24 — Quantitative Trading Strategies

"Alpha decays like radioactive material — half-life measured in months, not years."


After this chapter you will be able to:

  • Implement cross-sectional and time-series momentum strategies with realistic transaction costs
  • Estimate Ornstein-Uhlenbeck parameters and derive entry/exit thresholds for pairs trading
  • Construct factor-based composite signals and combine them
  • Detect and correct look-ahead bias, and apply Bonferroni multiple testing corrections to evaluate strategy significance
  • Compute net Sharpe after transaction costs at varying turnover rates

A quantitative trading strategy is a hypothesis about market inefficiency, expressed in code. The hypothesis might be: "assets that have outperformed over the past 12 months tend to continue outperforming over the next 3 months" (momentum). Or: "two assets that have historically moved together have temporarily diverged and will converge" (pairs trading). Or: "the market systematically underprices value stocks relative to growth stocks" (factor investing). These hypotheses have in common that they were discovered by analysing historical data, that they can be expressed as mathematical rules, and that they generate buy and sell signals for a trading algorithm to act on.

The central challenge of quantitative strategy development is the difference between in-sample and out-of-sample performance. With enough backtesting, almost any strategy can be made to look profitable historically. The more parameters a strategy has, the more degrees of freedom it has to fit the historical data, and the worse it will perform on new data. This overfitting problem is the graveyard of quantitative strategies. The standard tools against it — out-of-sample testing, cross-validation, walkforward testing, false discovery correction — are as important as the strategies themselves.

This chapter implements the three major families of quantitative strategies: time-series momentum, cross-sectional mean reversion and pairs trading, and factor-based investing. For each, we cover signal construction, portfolio construction, and realistic backtesting including transaction costs and market impact.


24.1 Strategy Architecture

Every quantitative strategy has three components:

  1. Signal generation: predict future returns
  2. Portfolio construction: convert signals to positions
  3. Risk management: constrain exposure
module Strategy = struct

  type signal = {
    ticker    : string;
    timestamp : int64;
    score     : float;   (* z-score or raw prediction *)
    confidence: float;
  }

  type position = {
    ticker  : string;
    quantity: float;     (* positive = long, negative = short *)
  }

  module type S = sig
    type state
    val init   : unit -> state
    (** Update state with new market data and return signals *)
    val update : state -> market_data -> signal list
    (** Convert signals to target positions *)
    val construct : state -> signal list -> position list
  end

  (** Strategy performance tracker *)
  type perf = {
    pnl           : float list;
    turnover      : float list;   (* daily two-way turnover *)
    position_count: int list;
    sharpe        : float;
    max_dd        : float;
  }

end

24.2 Momentum Strategies

Cross-sectional momentum: rank assets by past 12-1 month return, go long top decile, short bottom.

module Momentum = struct

  (** 12-1 month momentum signal *)
  let cross_sectional_signal ~returns_matrix ~lookback =
    (* returns_matrix: n_assets × n_days array *)
    let n_assets = Array.length returns_matrix in
    let n_days   = Array.length returns_matrix.(0) in
    if n_days < lookback + 22 then [||]
    else
      Array.init n_assets (fun i ->
        (* Cumulative return from lookback to 22 days ago *)
        let ret = Array.sub returns_matrix.(i) (n_days - lookback) (lookback - 22) in
        let cum = Array.fold_left (fun a r -> a +. r) 0.0 ret in   (* sum of log returns *)
        cum
      )

  (** Rank into deciles, go long top 10%, short bottom 10% *)
  let build_portfolio ~signals ~n_long ~n_short =
    let n = Array.length signals in
    let indexed = Array.init n (fun i -> (signals.(i), i)) in
    Array.sort (fun (a, _) (b, _) -> compare b a) indexed;
    let longs  = Array.sub indexed 0 n_long in
    let shorts = Array.sub indexed (n - n_short) n_short in
    let w_long  = 1.0 /. float_of_int n_long in
    let w_short = -. 1.0 /. float_of_int n_short in
    let lpos = Array.map (fun (_, i) -> (i, w_long)) longs in
    let spos = Array.map (fun (_, i) -> (i, w_short)) shorts in
    Array.append lpos spos

  (** Time-series momentum: sign of trailing return *)
  let time_series_signal ~prices ~lookback =
    let n = Array.length prices in
    if n <= lookback then 0.0
    else
      let ret = log (prices.(n - 1) /. prices.(n - 1 - lookback)) in
      if ret > 0.0 then 1.0 else -. 1.0

end

24.3 Mean Reversion and Statistical Arbitrage

module Mean_reversion = struct

  (** Ornstein-Uhlenbeck parameter estimation via OLS of Δx = μ + λx_{t-1} + ε *)
  let estimate_ou ~prices =
    let n     = Array.length prices in
    let xs    = Array.sub prices 0 (n - 1) in           (* x_{t-1} *)
    let dxs   = Array.init (n - 1) (fun i -> prices.(i + 1) -. prices.(i)) in
    (* OLS: Δx = a + b*x *)
    let n_f   = float_of_int (n - 1) in
    let sx    = Array.fold_left (+.) 0.0 xs in
    let sdx   = Array.fold_left (+.) 0.0 dxs in
    let sxx   = Array.fold_left (fun a x -> a +. x *. x) 0.0 xs in
    let sxdx  = Array.fold_left2 (fun a x dx -> a +. x *. dx) 0.0 xs dxs in
    let b     = (n_f *. sxdx -. sx *. sdx) /. (n_f *. sxx -. sx *. sx) in
    let a     = (sdx -. b *. sx) /. n_f in
    let kappa          = -. b in          (* mean reversion speed *)
    let long_run_mean  = a /. kappa in
    let resid  = Array.mapi (fun i dx -> dx -. a -. b *. xs.(i)) dxs in
    let sigma2 = Array.fold_left (fun acc r -> acc +. r *. r) 0.0 resid /. (n_f -. 2.0) in
    kappa, long_run_mean, sqrt sigma2

  (** Pairs trading: trade the spread s = p1 - h * p2 *)
  let hedge_ratio ~prices1 ~prices2 =
    (* OLS regression of p1 on p2: p1 = α + h*p2 + ε *)
    let n   = float_of_int (Array.length prices1) in
    let sx  = Array.fold_left (+.) 0.0 prices2 in
    let sy  = Array.fold_left (+.) 0.0 prices1 in
    let sxx = Array.fold_left (fun a x -> a +. x *. x) 0.0 prices2 in
    let sxy = Array.fold_left2 (fun a x y -> a +. x *. y) 0.0 prices2 prices1 in
    (n *. sxy -. sx *. sy) /. (n *. sxx -. sx *. sx)

  let spread ~prices1 ~prices2 ~hedge_ratio =
    Array.map2 (fun p1 p2 -> p1 -. hedge_ratio *. p2) prices1 prices2

  let zscore ~spread =
    let n    = float_of_int (Array.length spread) in
    let mean = Array.fold_left (+.) 0.0 spread /. n in
    let std  = sqrt (Array.fold_left (fun a x -> a +. (x -. mean) *. (x -. mean)) 0.0 spread /. n) in
    (spread.(Array.length spread - 1) -. mean) /. std

  (** Signal: enter when z-score > threshold, exit when z-score < exit_threshold *)
  let signal ~zs ~entry_threshold ~exit_threshold ~current_position =
    match current_position with
    | 0 ->
      if zs > entry_threshold then -1      (* short spread *)
      else if zs < -. entry_threshold then 1   (* long spread *)
      else 0
    | p ->
      if (p > 0 && zs > -. exit_threshold) || (p < 0 && zs < exit_threshold) then p
      else 0   (* close *)

end

24.4 Factor Investing

module Factor_strategies = struct

  type factor_signal = {
    momentum    : float;
    value       : float;
    quality     : float;
    low_vol     : float;
    size        : float;
  }

  (** Composite multi-factor score *)
  let composite ~s ~weights =
    s.momentum  *. weights.(0)
    +. s.value  *. weights.(1)
    +. s.quality *. weights.(2)
    +. s.low_vol *. weights.(3)
    +. s.size   *. weights.(4)

  (** Value signal: book-to-market ratio z-score *)
  let value_signal ~book_to_market_ratios =
    let n    = Array.length book_to_market_ratios in
    let mean = Array.fold_left (+.) 0.0 book_to_market_ratios /. float_of_int n in
    let std  = sqrt (Array.fold_left (fun a x -> a +. (x -. mean) *. (x -. mean))
                       0.0 book_to_market_ratios /. float_of_int n) in
    Array.map (fun bm -> (bm -. mean) /. std) book_to_market_ratios

  (** Low volatility anomaly: inverse of 1-year daily vol *)
  let low_vol_signal ~vols =
    let inv = Array.map (fun v -> if v > 1e-8 then 1.0 /. v else 0.0) vols in
    let mean = Array.fold_left (+.) 0.0 inv /. float_of_int (Array.length inv) in
    let std  = sqrt (Array.fold_left (fun a x -> a +. (x -. mean) *. (x -. mean))
                       0.0 inv /. float_of_int (Array.length inv)) in
    Array.map (fun v -> (v -. mean) /. std) inv

end

24.5 Backtesting

Backtesting — applying a strategy's rules to historical data and observing the hypothetical P&L — is the most valuable and most dangerous tool in quantitative strategy development. Its value is obvious: it provides the only feasible way to evaluate a strategy before risking real capital. Its danger is more subtle: the ease of testing means that most researchers test many strategies before settling on the ones that look good, and statistical chance guarantees that some fraction of random strategies will appear profitable in any historical sample.

The Multiple Testing Problem

If you test $M$ independent strategies at a 5% significance level, you expect approximately $0.05 \times M$ to appear significant by pure chance. If you test 100 strategies, 5 will appear to have significant alpha when they have none. If you test 1,000 strategies, 50 "work" in backtest and fail immediately in live trading. This is the multiple comparisons problem, and it is the single largest source of spurious results in quantitative finance.

The correct adjustment is the Bonferroni correction: to maintain a 5% family-wise error rate across $M$ tests, set the per-test significance level at $\alpha/M$. For $M = 100$, you need a $t$-statistic of approximately 3.3 (not 1.96) to declare significance. For $M = 1000$, you need $t \approx 4.0$. Harvey, Liu, and Zhu (2016) estimated that given the number of strategies reported in academic literature, a minimum $t$-ratio of 3.0 should be required for any new factor claim — far above the $t = 2.0$ standard used in most published papers.

Practical rule: for every strategy you report, estimate how many you tested to find it. If you ran 50 parameter combinations and report the best, your effective sample size for the significance test is much smaller than it appears.

Look-Ahead Bias

Look-ahead bias is accidentally using future information in a historical signal. It produces strategies that look spectacular in backtest and fail the moment they go live. Common sources:

  • Point-in-time data: financial statement data (earnings, book value) are restated. If you use today's database values for historical periods, you are using restated numbers that weren't available at the time. Use a point-in-time database.
  • Signal construction: if your signal at time $t$ is a z-score normalised using the full sample mean and standard deviation, you are using information from after $t$ to construct the signal at $t$. Normalise using only data up to $t$ (expanding window or rolling window).
  • Parameter fitting: if you fit a model (e.g., GARCH) to the full time series and then compute its "fitted" in-sample residuals as signals, every signal uses future data.
  • Index reconstitution: the S&P 500 today contains different stocks than in 2010. Backtesting using today's index membership introduces survivorship bias — all the companies that failed are missing from your universe.

The gold standard for avoiding look-ahead bias is a strict walk-forward test: train on data up to month $t$, generate signals for month $t+1$, record that P&L, then expand the training window and repeat. Never look backwards after observing the out-of-sample result.

Transaction Costs

A realistic backtest includes execution costs. For equity strategies, the primary costs are:

  • Bid-ask spread: paid on every round trip. At 5bp one-way for liquid large-caps, a daily-rebalancing strategy pays 10bp/day $\approx$ 25%/year in costs before any other expenses.
  • Market impact: for strategies with large order sizes, impact can dwarf the spread. Model impact as a function of participation rate using Almgren-Chriss or simpler linear models.
  • Borrow cost: short positions require borrowing stock. Hard-to-borrow names can cost 1–10% per year in borrow; this transforms profitable short theses into losses.
  • Commission, taxes: platform fees, stamp duty (UK), financial transaction taxes (France, Italy)

A backtest without transaction costs is not a business plan.

module Backtest = struct

  type trade = {
    day    : int;
    ticker : string;
    qty    : float;
    price  : float;
    side   : [`Buy | `Sell];
  }

  type result = {
    daily_pnl    : float array;
    sharpe       : float;
    max_drawdown : float;
    win_rate     : float;
    trade_count  : int;
    avg_turnover : float;
  }

  let sharpe_ratio ?(rf = 0.0) ?(annualise = 252) pnl_series =
    let n    = float_of_int (Array.length pnl_series) in
    let mean = Array.fold_left (+.) 0.0 pnl_series /. n in
    let var  = Array.fold_left (fun a r -> a +. (r -. mean) *. (r -. mean)) 0.0 pnl_series /. n in
    (mean -. rf) /. sqrt var *. sqrt (float_of_int annualise)

  let run ~universe ~signal_fn ~construct_fn ~price_matrix ~n_days =
    let n     = Array.length universe in
    let daily_pnl = Array.make n_days 0.0 in
    let last_weights = Array.make n 0.0 in
    let total_trades = ref 0 in
    let total_turnover = ref 0.0 in
    for day = 1 to n_days - 1 do
      let signals   = signal_fn day in
      let new_weights = construct_fn signals in
      (* P&L from returns *)
      let pnl = ref 0.0 in
      for i = 0 to n - 1 do
        let ret = safe_ret price_matrix i day in
        pnl := !pnl +. last_weights.(i) *. ret
      done;
      daily_pnl.(day) <- !pnl;
      (* Turnover *)
      let turnover = Array.fold_left2 (fun acc lw nw -> acc +. Float.abs (nw -. lw))
                       0.0 last_weights new_weights in
      total_turnover := !total_turnover +. turnover;
      total_trades   := !total_trades + (if turnover > 0.0 then 1 else 0);
      Array.blit new_weights 0 last_weights 0 n
    done;
    let sr  = sharpe_ratio daily_pnl in
    let mdd = Var.max_drawdown daily_pnl in
    let wins = Array.fold_left (fun c r -> if r > 0.0 then c + 1 else c) 0 daily_pnl in
    { daily_pnl; sharpe = sr; max_drawdown = mdd;
      win_rate = float_of_int wins /. float_of_int n_days;
      trade_count = !total_trades;
      avg_turnover = !total_turnover /. float_of_int n_days }

  and safe_ret pm i day =
    let n = Array.length pm.(i) in
    if day < n && day > 0 then (pm.(i).(day) -. pm.(i).(day - 1)) /. pm.(i).(day - 1)
    else 0.0

end

24.6 Transaction Costs and Turnover

let apply_transaction_costs ~pnl_series ~turnover_series ~cost_bps =
  let cost = cost_bps /. 10000.0 in
  Array.map2 (fun pnl turn -> pnl -. cost *. turn) pnl_series turnover_series

24.7 Chapter Summary

Quantitative strategies systematise the process of identifying and exploiting recurring patterns in financial data. The intellectual core of each strategy is a return prediction model — a signal that has historically predicted future performance. The operational challenge is building a complete system around that signal: portfolio construction, risk management, execution, and ongoing monitoring for strategy decay.

Momentum — the empirical finding that recent winners tend to continue winning over horizons of 1–12 months — is one of the most robust findings in empirical finance, documented across asset classes and geographies. Time-series momentum (comparing an asset's recent return to its own history) and cross-sectional momentum (comparing assets to each other) give related but distinct signals. Risk management in momentum strategies is critical because momentum crashes — rapid reversals when crowded positions exit simultaneously — are severe and asymmetric.

Pairs trading exploits the stationarity of spreads between related assets. If the log-price spread $\ln(S_1/S_2)$ is mean-reverting and can be modelled as an Ornstein-Uhlenbeck process, we can derive statistically principled entry and exit thresholds and compute the expected time to mean reversion. The Engle-Granger cointegration test provides the statistical foundation for identifying valid pairs (assets with a stable long-run relationship). In practice, pairs drift apart as business conditions change, requiring regular re-calibration.

Backtesting is the most dangerous and most important skill in quantitative strategy development. Look-ahead bias — accidentally using future information in historical signal construction — produces strategies that look spectacular historically and fail immediately in live trading. Transaction costs must be realistic: a strategy that turns over its portfolio daily needs to generate 10-20bp per day of gross alpha before costs just to break even. The Sharpe ratio and maximum drawdown together characterize a strategy's quality: Sharpe measures risk-adjusted return, and maximum drawdown measures the worst realized loss to capital, which determines the psychological and regulatory tolerance for the strategy.


Exercises

24.1 Implement cross-sectional 12-1 momentum on 20 synthetic assets. Backtest over 3 years and compute Sharpe before and after 5bp one-way transaction costs.

24.2 Estimate OU parameters for a simulated pairs spread. Backtest a ±2σ entry / ±0.5σ exit pairs strategy.

24.3 Build a multi-factor composite signal (equal-weight momentum, value, low-vol). Compare to each individual factor.

24.4 Study the impact of rebalancing frequency (daily, weekly, monthly) on Sharpe ratio and transaction costs for the momentum strategy.


Next: Chapter 25 — High-Performance Trading Infrastructure