More than one reason to move
CAPM has one factor, the market. In 1992 Fama and French showed that two more explain a lot of the differences in stock returns: size (small companies earned more than large ones, factor SMB, small minus big) and value (cheap stocks by book-to-market earned more than expensive ones, factor HML, high minus low). Later models added momentum, profitability and investment. Each factor is itself a return series, and an asset's loading on it is how strongly it moves with that series.
import numpy as np
rng = np.random.default_rng(1)
n = 250
market = rng.normal(0.0004, 0.010, size=n) # excess returns of three factors
smb = rng.normal(0.0001, 0.005, size=n)
hml = rng.normal(0.0001, 0.005, size=n)
factors = np.column_stack([market, smb, hml]) # shape (n, k) = (250, 3)
asset = 0.0002 + factors @ np.array([1.1, 0.5, -0.3]) + rng.normal(0, 0.004, size=n)
X = np.column_stack([np.ones(n), factors]) # design matrix: a column of ones for alpha
print(X.shape) # (250, 4)# The design matrix of three days and two factors
import numpy as np
factors = np.array([[0.010, -0.002], [-0.004, 0.001], [0.006, 0.003]])
X = np.column_stack([np.ones(len(factors)), factors])
print(X) # a column of ones, then market and SMBLeast squares in matrix form
With several factors the regression is asset = alpha + b1 * f1 + ... + bk * fk + error. Stack the factors next to a column of ones in a matrix X, and the least-squares coefficients solve the normal equations (X'X) c = X'y. In NumPy that is np.linalg.solve(X.T @ X, X.T @ y). It is the same machinery as the earlier single-asset regression, now with k + 1 unknowns. The first coefficient is alpha, the rest are the loadings.
import numpy as np
rng = np.random.default_rng(1)
n = 250
factors = np.column_stack([rng.normal(0.0004, 0.010, size=n), rng.normal(0.0001, 0.005, size=n), rng.normal(0.0001, 0.005, size=n)])
asset = 0.0002 + factors @ np.array([1.1, 0.5, -0.3]) + rng.normal(0, 0.004, size=n)
X = np.column_stack([np.ones(n), factors])
coef = np.linalg.solve(X.T @ X, X.T @ asset)
print(np.round(coef, 4)) # [alpha, market, SMB, HML]: the loadings come out near [1.1, 0.5, -0.3]
residual = asset - X @ coef
r2 = 1 - np.sum(residual ** 2) / np.sum((asset - asset.mean()) ** 2)
print(round(float(r2), 4))How much do the factors explain?
R-squared answers it: the share of the asset's variation that the factors explain, 1 - SSres / SStot, between 0 (nothing) and 1 (everything). A diversified equity portfolio often has an R-squared above 0.9 on the Fama–French factors: its risk is almost all factor risk. A single stock has a lower one, and the rest, the residual variation, is specific to the company. Adding a factor can never lower R-squared in sample, so a higher number alone does not prove a factor matters.
# What the model implies: the expected return from the loadings and the factor premia
import numpy as np
betas = np.array([1.1, 0.5, -0.3]) # market, SMB, HML loadings
premia = np.array([0.0006, 0.0002, 0.0002]) # expected daily factor returns
rf = 0.0001
expected = rf + np.sum(betas * premia)
print(round(float(expected), 6)) # 0.0008 per day, about 20% a year on 250 days- A factor model regresses excess returns on several factors:
X = [ones | factors], coefficients[alpha, beta_1..beta_k]. - Solve the normal equations
np.linalg.solve(X.T @ X, X.T @ y); R-squared is1 - SSres / SStotand never falls when you add a factor. - The model implies
rf + sum(betas * premia); loadings and premia are estimates, and a factor premium can disappear.
Next up: the information coefficient, measuring whether a signal predicts returns at all.
Lab · what's graded
- Set up the regression with
design_matrix25 pts - Estimate the loadings with
fit_factor_model25 pts - Score the fit with
r_squared25 pts - Price the exposures with
factor_expected_return25 pts