Quant Memo

Build It Yourself

← All guides

Build a Trend-Following System

Implement a CTA-style time-series momentum system across a multi-asset universe, a trend signal, per-asset volatility scaling to a target risk, portfolio combination, costs, and see the positive skew and crisis-alpha convexity that make trend a portfolio diversifier.

Trend-following is the strategy that runs the world's managed-futures (CTA) industry, and it rests on one stubborn empirical fact: an asset that has gone up over the last year tends to keep going up over the next month, and one that has fallen tends to keep falling. Moskowitz, Ooi & Pedersen call this time-series momentum, distinct from the cross-sectional Momentum of dual momentum, because here each asset is judged only against its own past, not ranked against peers. You can be long everything, short everything, or anywhere between.

But the signal is the easy half. The ingredient that makes a real CTA is volatility scaling, sizing every position so each contributes equal risk. This guide builds both in numpy/pandas, reusing the shift-clean skeleton from backtest from scratch, and then shows the property traders actually pay for: positive skew and crisis alpha.

A multi-asset universe

Trend works because it is diversified across many bets, no single market has to cooperate. We simulate six assets whose drift switches between regimes, so genuine (but whippy) trends exist to be captured. Swap the synthetic block for yf.download(tickers)["Close"] across a basket of futures-proxy ETFs (equities, bonds, commodities, FX).

import numpy as np
import pandas as pd

rng = np.random.default_rng(11)
n, n_assets = 3000, 6
idx = pd.bdate_range("2010-01-01", periods=n)
names = [f"A{i}" for i in range(n_assets)]

prices = {}
for i in range(n_assets):
    base_vol = rng.uniform(0.008, 0.018)          # each asset has its own vol
    drift, logp = 0.0, [np.log(100)]
    for t in range(1, n):
        if rng.random() < 1/160:                  # regime change ~ every 8 months
            drift = rng.normal(0, 0.0011)          # modest, persistent trend
        logp.append(logp[-1] + drift + rng.normal(0, base_vol))
    prices[names[i]] = np.exp(np.array(logp))
px = pd.DataFrame(prices, index=idx)
rets = px.pct_change().fillna(0.0)

The signal: sign of the trailing 12-month return

The canonical time-series-momentum signal is simply the sign of the past-12-month return. Positive → go long; negative → go short. That is the entire directional view.

signali,t=sign ⁣(ri,t252t)\text{signal}_{i,t} = \operatorname{sign}\!\big(r_{i,\,t-252 \to t}\big)

look = 252
signal = np.sign(px.pct_change(look))            # +1 long / -1 short, per asset

A 12-month lookback is slow on purpose: momentum is a low-turnover signal, and trading it fast just pays the spread to churn. Moving-average crossovers or channel breakouts are common alternatives, they produce nearly the same positions, because they are all just low-pass filters on price.

The key ingredient: volatility scaling

Here is what separates a CTA from a hobbyist. A raw ±1\pm1 signal puts the same dollar on a sleepy bond future and a wild commodity, so the commodity dominates the portfolio's risk and the bond contributes nothing. Fix it by sizing each position inversely to its own volatility, targeting a constant risk per leg:

wi,t=signali,tσtargetσ^i,t,σ^i,t=252  std(ri,t60t)w_{i,t} = \operatorname{signal}_{i,t}\cdot\frac{\sigma_{\text{target}}}{\hat{\sigma}_{i,t}}, \qquad \hat{\sigma}_{i,t} = \sqrt{252}\;\operatorname{std}\big(r_{i,\,t-60\to t}\big)

target_vol = 0.15                                        # 15% annualized per leg
real_vol = rets.rolling(60).std() * np.sqrt(252)         # trailing annualized vol
scale = (target_vol / real_vol).clip(upper=5.0)          # cap leverage per leg
weights = (signal * scale).shift(1).fillna(0.0)          # shift(1) = no lookahead

The clip matters: when a market goes quiet, 1/σ^1/\hat\sigma explodes and would demand insane leverage, so cap it. The shift(1) is the same non-negotiable discipline as always, today's weight is computed from data through yesterday. Skip it and you size positions using the very returns you're about to earn.

Does the scaling actually equalize risk? Measure the realized vol each leg contributes:

per_leg = (weights * rets)
print((per_leg.std() * np.sqrt(252)).round(3).to_dict())
# {'A0': 0.146, 'A1': 0.146, 'A2': 0.146, 'A3': 0.147, 'A4': 0.146, 'A5': 0.146}

Every leg lands within a hair of the 15% target, regardless of how wild the underlying asset is. That is vol targeting working: risk, not dollars, is the unit of allocation.

Combine, cost, evaluate

Average the legs into one portfolio, charge costs on turnover, and score it.

port_gross = (weights * rets).sum(axis=1) / n_assets
turn = weights.diff().abs().sum(axis=1).fillna(0.0)
port_net = port_gross - turn * 0.0002 / n_assets         # 2 bps per unit turnover

eq = (1 + port_net).cumprod()
sharpe = (port_net.mean() / port_net.std()) * np.sqrt(252)
ann_vol = port_net.std() * np.sqrt(252)
maxdd = (eq / eq.cummax() - 1).min()
print(f"Sharpe {sharpe:.2f} | Ann vol {ann_vol:.1%} | MaxDD {maxdd:.1%}")
# Sharpe 0.92 | Ann vol 6.1% | MaxDD -11.7%

Net Sharpe 0.92 with a −11.7% drawdown, realistic for trend; anyone quoting a backtest Sharpe of 3 is fooling themselves. Notice the portfolio vol is 6.1%, not 15%, even though each leg targets 15%. That gap is diversification: combining NN roughly-uncorrelated 15% legs gives about 15%/N15%/6=6.1%15\%/\sqrt{N} \approx 15\%/\sqrt{6} = 6.1\%. If you want the book to run at 15%, add a final portfolio-level scalar, that's exactly how CTAs dial their fund to a headline risk target.

Why trend has positive skew, the convexity

Now the property institutions actually buy trend for. Look at the skew of the monthly returns:

monthly = port_net.resample("ME").apply(lambda r: (1 + r).prod() - 1)
print(f"monthly skew {monthly.skew():.2f}")              # +0.66

Positive skew (+0.66), the opposite of nearly every other strategy, which sells insurance and earns small steady premiums until a fat left tail wipes them out. Trend does the reverse: many small losing whipsaws in choppy markets (the premium it pays), punctuated by a few large gains when a real trend runs. Mechanically it is a long-convexity, long-volatility payoff, closer to a straddle than to a carry trade.

That convexity is the source of crisis alpha: when a market crashes over weeks or months, the trend signal flips short and the position profits from the very move that is destroying long-only portfolios. Engineer a slow −45% grind in one asset and check the leg's P&L:

px2 = px.copy()
crash = np.ones(n); crash[2000:2200] = np.exp(np.linspace(0, -0.6, 200))
px2["A0"] *= crash                                        # sustained decline
rets2 = px2.pct_change().fillna(0.0)
w2 = (np.sign(px2.pct_change(look)) *
      (target_vol / (rets2.rolling(60).std()*np.sqrt(252))).clip(upper=5.0)
      ).shift(1).fillna(0.0)
print((w2["A0"] * rets2["A0"]).iloc[2000:2200].sum())    # +0.727

The leg makes +0.73 during the crash. That is why a trend sleeve historically paid off in 2008 and early 2020 while equities collapsed, it is long-only portfolios' natural hedge, bought for a running cost of whipsaw premium rather than an explicit option price.

Where this breaks

  • Range-bound whipsaw. Trend's kryptonite is a choppy, directionless market: the signal flips back and forth, each flip pays the spread, and the small losses accumulate. The 2010s post-crisis chop was a lost decade for CTAs. The positive skew is this: you lose small and often, betting on the rare big win.
  • Vol estimation lags. A 60-day trailing vol reacts slowly. When volatility spikes overnight, your position is momentarily too large before the estimate catches up, the exact moment you least want to be oversized. Faster (EWMA) vol estimates help but add turnover.
  • Correlations go to one. Vol scaling equalizes marginal risk but assumes legs stay diversified. In a panic, cross-asset correlations jump and your "diversified" book becomes one big bet, realized portfolio vol overshoots the target badly.
  • Costs and capacity. We charged a flat 2 bps; real slippage and impact grow with size, and trend at scale trades large notionals. The retail-tractable version lives in liquid futures/ETFs.

The value here is the machinery: a slow time-series signal, per-asset vol scaling to a risk budget, shift-clean weights, turnover costs, and, most importantly, evaluating the shape of returns (skew, crisis behavior), not just the Sharpe. Compare against the full Trend-Following Futures write-up, then run it on a real multi-asset basket and confirm the skew stays positive.

The theory behind it

Related strategies

Further reading

  • Moskowitz, Ooi & Pedersen, Time Series Momentum (JFE, 2012)
  • Hurst, Ooi & Pedersen, A Century of Evidence on Trend-Following Investing (2017)
  • Ernest Chan, Algorithmic Trading: Winning Strategies and Their Rationale (2013)
  • Gary Antonacci, Dual Momentum Investing (2014)

Code is for education, not investment advice. Test everything on paper before risking capital.