Lesson 4.1.6 · 75 min
Factor models: CAPM, Fama-French, PCA and attribution
A factor model separates what a portfolio earns by being exposed to known premia from what it earns on its own. This lesson regresses 20 synthetic funds with published alphas and loadings on the CAPM and on six factors in the format of Kenneth French's library, and checks what is recovered, what fails (the false positive, market timing, put selling, style drift) and what the principal components of a universe whose true factors are known can see.
- Module
- 4.1 · Portfolio theory and optimisation
- Duration
- 75 min
- Prerequisites
- 2.1.1 · Hypothesis tests, p-values, bootstrap and regression with robust standard errors, 2.1.8 · The multiple testing problem: Bonferroni, FDR and the “factor zoo”, 2.3.5 · Factors (value, quality, low volatility, size) and event strategies, 2.6.2 · Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code
From the CAPM to six factors
The CAPM (Sharpe, 1964) says that, in equilibrium, the expected excess return of any asset is its beta times the market premium. Applied to a portfolio as a time series, the regression
separates what the portfolio earns by being exposed to the market () from what it earns on its own (). Lesson 2.6.2 used it to measure a strategy against an index. The problem is that the market is not the only premium that gets paid. Fama and French (1993) added two factors built as long-short stock portfolios: size (SMB, small minus big) and value (HML, low price relative to book value minus high). Carhart (1997) added momentum (the winners of the last twelve months, excluding the last one, minus the losers) and Fama and French (2015), profitability (RMW, profitable firms minus unprofitable ones) and investment (CMA, firms that invest little minus those that invest a lot). With all six:
Mermaid source
flowchart TB C[CAPM: market] --> F3[FF3: + SMB, HML] F3 --> C4[Carhart: + MOM] F3 --> F5[FF5: + RMW, CMA] C4 --> F6[Six factors] F5 --> F6
An alpha in a model is a return that that model does not explain. Each factor added turns into exposure something that used to be alpha. If factor premia can be bought cheaply (style index funds, futures), a manager who only collects factor premia adds nothing that cannot be replicated. How factors are built from stock data (quintile sorts, Fama-MacBeth, IC) is lesson 2.3.5; here they are used ready-made, to evaluate portfolios.
The data are synthetic and come with the known answer. ff4_factores_mensuales.csv has six monthly factors from
2006 to 2025 in the format of Kenneth French's library (as decimals), consistent with the universe of lesson 4.1.4.
ff4_fondos_mensuales.csv has 20 fictitious funds generated with alphas and loadings published in
ff4_fondos_verdad.csv, among them a fund with real alpha, one with small alpha, several with no alpha, two with
negative alpha because of fees, two nonlinear ones and one that changes style. No result in this lesson says anything
about real funds.
import numpy as np
import pandas as pd
import statsmodels.api as sm
fac = pd.read_csv("ff4_factores_mensuales.csv", index_col="mes")
fondos = pd.read_csv("ff4_fondos_mensuales.csv").pivot(
index="mes", columns="fondo", values="rendimiento")
verdad = pd.read_csv("ff4_fondos_verdad.csv", index_col="fondo")
exc = fondos.sub(fac["rf"], axis=0) # excess over the risk-free rate
F6 = ["mkt_rf", "smb", "hml", "rmw", "cma", "mom"]
print(fac.shape, exc.shape, fac.index[0], "to", fac.index[-1])
resumen = pd.DataFrame({
"media_%": 1200 * fac[F6].mean(),
"vol_%": 100 * np.sqrt(12) * fac[F6].std(),
"peor_mes_%": 100 * fac[F6].min(),
})
resumen["sharpe"] = resumen["media_%"] / resumen["vol_%"]
resumen.round(2)(240, 7) (240, 20) 2006-01 to 2025-12
media_% vol_% peor_mes_% sharpe
mkt_rf 9.27 22.22 -28.94 0.42
smb 0.18 8.01 -8.75 0.02
hml -2.45 8.35 -7.77 -0.29
rmw 3.52 7.67 -6.15 0.46
cma 1.69 6.94 -7.41 0.24
mom 4.05 17.64 -33.13 0.23The synthetic market has a mean premium of 9.27% a year with 22.2% volatility and a worst month of −28.9%. Value (HML) has a negative premium in the sample, profitability (RMW) the highest Sharpe (0.46) and momentum a worst month of −33.1%, the 2009 crash that the data imitate.
The primary source: Kenneth French's library
The real factors are published for free in Kenneth French's data library (Dartmouth), which is the primary source:
Fama and French's papers use those series and almost the whole industry takes them as the reference. Each file is a
ZIP with a CSV in its own format. As consulted on October 22, 2026, F-F_Research_Data_5_Factors_2x3_CSV.zip has
three lines of text (“This file was created using the 202608 CRSP database”, where the risk-free rate comes from:
Ibbotson until May 2024 and the ICE BofA one-month bill index since June), a blank line, the header
,Mkt-RF,SMB,HML,RMW,CMA,RF, the months in YYYYMM format with the values in percent from July 1963 to August
2026, another blank line and a second table, Annual Factors: January-December, with the years. Momentum goes in
another file, F-F_Momentum_Factor_CSV.zip, with a longer text header that warns that missing data are marked with
−99.99 or −999. Lines end in CRLF.
A robust reader does not count header lines, because they change from one file to another: it looks for the first line that starts with a comma and reads up to the first one that does not start with a year. The cell tests it with a text that copies that layout with the course's synthetic values:
import io
# A file with the same layout as F-F_Research_Data_5_Factors
# _2x3.csv: text header, YYYYMM months, values in %, and then
# the annual factors. The values are the course's synthetic ones.
cab = ",Mkt-RF,SMB,HML,RMW,CMA,RF"
filas = [f"{m.replace('-', '')},"
+ ",".join(f"{100 * fac.loc[m, c]:8.2f}"
for c in ["mkt_rf", "smb", "hml", "rmw", "cma",
"rf"])
for m in fac.index[:3]]
texto = "\r\n".join(
["Sample file (K. French library format).",
"", cab, *filas, "", " Annual Factors: January-December ",
cab, " 2006, 10.00, 1.00, 2.00, 3.00, 4.00,"
" 5.00"])
def leer_french(texto):
"""First (monthly) table of a CSV from French's library."""
lineas = texto.splitlines()
ini = next(i for i, l in enumerate(lineas) if l.startswith(","))
fin = next(i for i in range(ini + 1, len(lineas))
if not lineas[i].strip()[:1].isdigit())
df = pd.read_csv(io.StringIO("\n".join(lineas[ini:fin])),
index_col=0)
df.index = pd.PeriodIndex(df.index.astype(str), freq="M")
df.columns = (df.columns.str.strip().str.lower()
.str.replace("-", "_"))
df = df.replace([-99.99, -999], np.nan) # missing values
return df / 100 # from % to decimals
leer_french(texto) mkt_rf smb hml rmw cma rf
2006-01 0.0080 -0.0235 0.0296 0.0151 0.0107 0.0034
2006-02 -0.0064 -0.0207 0.0091 0.0099 0.0265 0.0033
2006-03 0.0236 -0.0360 -0.0225 0.0560 0.0189 0.0041With a network connection, the same reader works for the real files. The code downloads the ZIP with urllib (from
the standard library), reads the CSV inside and joins the five factors with momentum by month; it is not run in the
course:
import io
import urllib.request
import zipfile
BASE = ("https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/"
"ftp/")
def descargar_french(nombre):
"""Download a ZIP from the library and return the CSV as text."""
with urllib.request.urlopen(BASE + nombre, timeout=30) as r:
z = zipfile.ZipFile(io.BytesIO(r.read()))
with z.open(z.namelist()[0]) as f:
return f.read().decode("latin-1")
ff5 = leer_french(descargar_french(
"F-F_Research_Data_5_Factors_2x3_CSV.zip"))
mom = leer_french(descargar_french("F-F_Momentum_Factor_CSV.zip"))
ff6 = ff5.join(mom, how="inner")Twenty funds with the known answer
The regression is the same for every fund: least squares with a constant, on monthly data, and Newey-West errors with three lags (lesson 2.1.1), which correct for heteroskedasticity and some autocorrelation of the residuals. The monthly alpha is multiplied by 12 to annualize it.
def regresion(y, X, hac=True):
"""OLS with a constant; Newey-West errors (3 months) if hac."""
m = sm.OLS(y, sm.add_constant(X))
if hac:
return m.fit(cov_type="HAC", cov_kwds={"maxlags": 3})
return m.fit()
filas = {}
for f in exc:
capm = regresion(exc[f], fac[["mkt_rf"]])
ff = regresion(exc[f], fac[F6])
filas[f] = {
"alfa_verdad_%": 100 * verdad.loc[f, "alfa_anual"],
"alfa_capm_%": 1200 * capm.params.iloc[0],
"beta_capm": capm.params.iloc[1],
"alfa_ff6_%": 1200 * ff.params.iloc[0],
"t_ff6": ff.tvalues.iloc[0],
"r2_ff6": ff.rsquared,
}
tab = pd.DataFrame(filas).T
tab.round(2) alfa_verdad_% alfa_capm_% beta_capm alfa_ff6_% t_ff6 r2_ff6
F01 -0.1 0.04 1.00 0.05 0.55 1.00
F02 -1.0 -0.24 1.02 -0.24 -0.56 0.99
F03 0.0 -1.60 1.01 -0.72 -0.74 0.97
F04 0.0 -1.18 1.07 -1.05 -1.02 0.97
F05 0.0 3.31 0.85 0.09 0.12 0.97
F06 0.0 1.72 0.95 -1.67 -1.52 0.97
F07 0.0 -0.65 1.28 0.64 0.46 0.96
F08 3.0 3.52 0.98 3.29 2.86 0.96
F09 1.5 3.04 1.00 2.90 2.57 0.95
F10 0.0 1.45 0.73 -0.09 -0.13 0.98
F11 2.0 3.21 0.03 2.24 2.05 0.38
F12 0.0 2.88 0.67 3.13 4.15 0.96
F13 6.0 -1.19 0.54 -1.21 -1.82 0.96
F14 0.0 -3.70 1.01 -2.79 -1.98 0.94
F15 1.0 2.24 0.57 0.25 0.32 0.94
F16 -2.5 -1.99 1.00 -2.18 -3.15 0.99
F17 0.0 0.42 0.78 -0.39 -0.55 0.97
F18 0.0 -1.17 1.08 2.06 2.55 0.98
F19 -1.5 -0.09 2.00 -0.27 -0.53 1.00
F20 0.0 0.99 0.99 1.12 0.76 0.94The header figure puts the true alpha (hollow square) next to the one measured with six factors (filled square) and its 95% interval. For most funds the interval contains the truth. Four readings of the table:
- The CAPM gets style funds wrong. F05 (quality) measures a CAPM alpha of 3.31% and 0.09% with six factors; F06 (momentum), 1.72% and −1.67%; F03 (deep value), −1.60% and −0.72%. The CAPM alpha of a style fund is, above all, the premium of its style in the sample.
- R² separates active funds from index funds. F01 (broad index) and F19 (2x leveraged) have an R² of 1.00; F11, a market-neutral long-short fund, 0.38: almost all of its risk is idiosyncratic.
- The measured alpha has a large error. With 20 years, the standard error of the annual alpha is the idiosyncratic volatility divided by √20: for a fund with 5% idiosyncratic volatility, 1.1 points. F09, with a true 1.5%, measures 2.90%; F19, with a true −1.5%, measures −0.27%.
- Three funds come out wrong for different reasons. F12 and F13 are nonlinear and F14 changes style; they are covered further down.
Loadings are recovered much better than alphas, because the factors move much more than their mean:
cargas = pd.DataFrame(
{f: regresion(exc[f], fac[F6]).params[1:] for f in exc},
index=F6).T
err = cargas - verdad[[f"b_{c}" for c in F6]].values
lineales = [f for f in exc if f not in ("F12", "F13", "F14")]
print("mean absolute error of the loadings (17 linear funds):")
err.loc[lineales].abs().mean().round(3)mean absolute error of the loadings (17 linear funds):
mkt_rf 0.007
smb 0.023
hml 0.040
rmw 0.028
cma 0.042
mom 0.008
dtype: float64In the 17 linear funds, the mean absolute error of the market loading is 0.007 and that of the others between 0.008 and 0.042. A factor regression is a good instrument for measuring exposures and a bad one for measuring alphas.
Attribution: where the return comes from
With the estimated loadings, the mean excess return of a fund breaks down exactly into alpha plus loading times the mean of each factor (the regression with a constant passes through the means):
F10 is the textbook case: a CAPM alpha of 1.45%.
def atribucion(f, factores):
"""Annual mean return: alpha + loading × mean of each factor."""
m = regresion(exc[f], fac[factores], hac=False)
partes = 1200 * m.params[1:] * fac[factores].mean()
partes["alfa"] = 1200 * m.params.iloc[0]
partes["total"] = 1200 * exc[f].mean()
return partes
pd.DataFrame({"capm": atribucion("F10", ["mkt_rf"]),
"ff6": atribucion("F10", F6)}).round(2) capm ff6
alfa 1.45 -0.09
cma NaN 0.44
hml NaN -1.24
mkt_rf 6.79 7.51
mom NaN 0.34
rmw NaN 1.21
smb NaN 0.07
total 8.25 8.25The mean excess return of 8.25% a year is the same in both columns; the split changes. With the CAPM, 6.79 points are market and 1.45 alpha. With six factors, the market rises to 7.51 (F10's market beta with the other factors is higher than its CAPM beta), profitability adds 1.21, investment 0.44, momentum 0.34, value subtracts 1.24 and the alpha is left at −0.09. The fund had exposures to premia that the CAPM does not measure; it had no alpha.
Many funds, some of them lucky
Testing the alpha of 20 funds means running 20 tests. At a 5% level, even if none had alpha, one would be expected to come out significant by chance. Lesson 2.1.8 explains the corrections; here they are applied with the truth in view:
from statsmodels.stats.multitest import multipletests
p = pd.Series({f: regresion(exc[f], fac[F6]).pvalues.iloc[0]
for f in exc})
holm = multipletests(p, alpha=0.05, method="holm")
bh = multipletests(p, alpha=0.05, method="fdr_bh")
sig = pd.DataFrame({"alfa_verdad_%": tab["alfa_verdad_%"],
"t": tab["t_ff6"], "p": p,
"holm": holm[0], "fdr_bh": bh[0]})
sig[sig["p"] < 0.05].round(4) alfa_verdad_% t p holm fdr_bh
F08 3.0 2.8593 0.0042 False True
F09 1.5 2.5705 0.0102 False True
F11 2.0 2.0466 0.0407 False False
F12 0.0 4.1459 0.0000 True True
F14 0.0 -1.9828 0.0474 False False
F16 -2.5 -3.1471 0.0016 True True
F18 0.0 2.5541 0.0106 False TrueSeven funds have p < 0.05. With the truth known:
| Fund | True alpha | p | What is it? | Holm | BH |
|---|---|---|---|---|---|
| F12 | 0 | 0.0000 | Market timing: spurious alpha from a misspecified model | yes | yes |
| F16 | −2.5% | 0.0016 | High fee: real negative alpha | yes | yes |
| F08 | 3% | 0.0042 | Real alpha | no | yes |
| F09 | 1.5% | 0.0102 | Small real alpha, measured with luck | no | yes |
| F18 | 0 | 0.0106 | False positive | no | yes |
| F11 | 2% | 0.0407 | Real alpha | no | no |
| F14 | 0 | 0.0474 | Style drift: spurious alpha | no | no |
Holm controls the probability of making any false positive among the 20 and keeps F12 and F16: it removes the false positive F18, but also the real alphas of F08, F09 and F11. Benjamini-Hochberg controls the proportion of false discoveries and accepts five: three with real or real negative alpha, the false positive F18 and the spurious F12. No correction fixes F12: its t of 4.15 is real, but it measures a badly posed model, not skill.
F18 is a “contrarian” fund with a loading of −0.4 on momentum and zero alpha. In this sample its residual had a positive mean of the order of two standard errors. With 20 funds, it is to be expected that this happens to one.
Mermaid source
flowchart TB
A["Alpha with t > 2"] --> M{Many funds?}
M -- yes --> C[Holm or BH]
M -- no --> N{Nonlinear?}
C --> N
N -- yes --> T[Add TM or HM]
N -- no --> E{Stable loading?}
T --> E
E -- no --> V[Rolling windows]
E -- yes --> R[Credible alpha]Market timing and option selling
Two funds have returns that are not linear in the market:
- F12 does market timing. It raises its exposure before rallies and cuts it before declines. The result is a convex relationship with the market; Treynor and Mazuy (1966) modeled it with a quadratic term: . Truth: , .
- F13 sells puts. It collects a fixed premium and, when the market falls, loses more than its normal beta. Henriksson and Merton (1981) modeled it with a term: a different beta in declines. Truth: a year (the premium), normal beta 0.4 and an extra beta of 0.25 in declines.
X = fac[F6].copy()
X["mkt2"] = fac["mkt_rf"] ** 2 # Treynor-Mazuy
X["mkt_neg"] = np.minimum(fac["mkt_rf"], 0) # Henriksson-Merton
tm = regresion(exc["F12"], X[F6 + ["mkt2"]])
hm = regresion(exc["F13"], X[F6 + ["mkt_neg"]])
pd.DataFrame({
"F12_lineal": [tab.loc["F12", "alfa_ff6_%"], np.nan],
"F12_TM": [1200 * tm.params.iloc[0], tm.params.iloc[-1]],
"F13_lineal": [tab.loc["F13", "alfa_ff6_%"], np.nan],
"F13_HM": [1200 * hm.params.iloc[0], hm.params.iloc[-1]],
}, index=["alfa_%", "coef_no_lineal"]).round(2) F12_lineal F12_TM F13_lineal F13_HM
alfa_% 3.13 0.68 -1.21 5.83
coef_no_lineal NaN 0.44 NaN 0.24The linear model gets both wrong, and in opposite directions. To F12 it attributes 3.13% of alpha: the mean of is positive and, without the quadratic term, it ends up in the constant. With Treynor-Mazuy the alpha drops to 0.68% (not significant) and comes out at 0.44, close to 0.5. To F13 it attributes −1.21%, and with Henriksson-Merton the alpha comes out at 5.83% and the extra beta at 0.24: the truth.
So what is F13's 6%? What it collects for insuring others against declines. What it costs:
b_mkt = hm.params.iloc[1]
extra = hm.params.iloc[-1]
print(f"normal beta {b_mkt:.2f}, extra beta in declines {extra:.2f}")
coste_put = 1200 * extra * X["mkt_neg"].mean()
alfa_hm = 1200 * hm.params.iloc[0]
print(f"alpha (premium collected): {alfa_hm:6.2f} %")
print(f"mean of extra·min(mkt, 0): {coste_put:6.2f} %")
peores = fac["mkt_rf"].nsmallest(3).index
(100 * pd.DataFrame({"mkt_rf": fac.loc[peores, "mkt_rf"],
"F13": exc.loc[peores, "F13"]})).round(2)normal beta 0.40, extra beta in declines 0.24
alpha (premium collected): 5.83 %
mean of extra·min(mkt, 0): -5.68 %
mkt_rf F13
mes
2018-10 -28.94 -18.54
2011-09 -21.28 -12.79
2025-03 -20.74 -13.54The extra beta of 0.24 in declines has an average cost of 5.68% a year, almost the whole alpha. In the three worst months of the synthetic market (−28.9%, −21.3% and −20.7%), F13 loses 18.5%, 12.8% and 13.5%. The figure shows the shape: averages by market bucket over a kinked line with slope 0.40 in rallies and 0.64 in declines. An “alpha” collected in exchange for tail risk is not skill; it is a risk premium under another name (lesson 3.6.1). The linear regression, on top of that, hides it.
Style drift
F14 was a value fund (a loading of 0.8 on HML) until December 2015 and since January 2016 has been a growth fund (−0.3). A single regression over the whole sample averages the two:
movil = {}
for i in range(36, len(exc) + 1):
v = slice(i - 36, i)
m = regresion(exc["F14"].iloc[v], fac[F6].iloc[v], hac=False)
movil[exc.index[i - 1]] = m.params["hml"]
movil = pd.Series(movil)
mitades = {t: regresion(exc["F14"].loc[a:b], fac[F6].loc[a:b],
hac=False).params["hml"]
for t, (a, b) in {"2006-2015": ("2006-01", "2015-12"),
"2016-2025": ("2016-01", "2025-12")
}.items()}
print("whole sample:", round(cargas.loc["F14", "hml"], 2),
"· by halves:", {k: round(float(v), 2)
for k, v in mitades.items()})
movil.iloc[::24].round(2)whole sample: 0.22 · by halves: {'2006-2015': 0.85, '2016-2025': -0.35}
2008-12 0.94
2010-12 0.84
2012-12 0.86
2014-12 0.79
2016-12 0.51
2018-12 -0.39
2020-12 -0.35
2022-12 -0.36
2024-12 -0.35
dtype: float64With the whole sample, the value loading comes out at 0.22: a “slightly value” fund that never existed. By halves it comes out at 0.85 and −0.35, close to the truth. The 36-month rolling regression sees the change, but late: it crosses zero in September 2018, almost three years later. In addition, the misspecified average leaves an alpha of −2.79% with t −1.98, which is not in the fund: it is the consequence of imposing a fixed loading on a fund that does not have one. Rolling windows, piecewise regressions or time-varying loading models (the Kalman filter of lesson 2.1.6) are the tools for this.
Risk attribution of a portfolio
Attribution also works for risk. With a factor model , the variance of a portfolio is with , the portfolio's exposure to each factor. By Euler, as in lesson 4.1.4, each factor contributes and the rest is idiosyncratic.
The universe of lesson 4.1.4 was generated with a published ten-factor model: true loadings in
universo4_verdad.csv, factor volatilities and correlations by regime in universo4_factores_verdad.csv and the
realized daily factor returns in universo4_factores_diarios.parquet. The true attribution of the calm regime and the
one estimated with daily regressions on the calm sessions can be computed and compared:
precios = pd.read_parquet("universo4_precios.parquet")
precios = precios.set_index("fecha")
diarios = pd.read_parquet("universo4_factores_diarios.parquet")
diarios = diarios.set_index("fecha")
r = precios.pct_change()
r.iloc[0] = precios.iloc[0] / 100 - 1
cols = [c for c in precios if c not in ("MONET", "RF_T13")]
ex_d = r[cols].sub(diarios["rf"], axis=0)
nombres = [c[2:] for c in diarios if c.startswith("f_")]
f_d = diarios[[f"f_{k}" for k in nombres]].set_axis(nombres, axis=1)
# truth: B, factor covariance and D of the calm regime
uv = pd.read_csv("universo4_verdad.csv", index_col="activo").loc[cols]
B = uv[[f"b_{k}" for k in nombres]].values
fv = pd.read_csv("universo4_factores_verdad.csv")
fv = fv[fv["regimen"] == "calma"].set_index("factor").loc[nombres]
C = fv[[f"corr_{k}" for k in nombres]].values
Sf = np.outer(fv["vol_anual"], fv["vol_anual"]) * C
D = np.diag(uv["vol_idio"] ** 2)
cvr = pd.read_csv("universo4_cov_verdad.csv")
cvr = cvr[cvr["regimen"] == "calma"].set_index("activo")
dif = B @ Sf @ B.T + D - cvr.loc[cols, cols].values
print(f"max |BΣfB' + D − Σ_calm|: {np.abs(dif).max():.1e}")
# estimate: daily regressions on the calm sessions
calma = diarios["regimen"] == "calma"
Xf = sm.add_constant(f_d[calma])
coef = np.linalg.lstsq(Xf.values, ex_d[calma].values, rcond=None)[0]
B_e = coef[1:].T
res = ex_d[calma].values - Xf.values @ coef
Sf_e = 252 * np.cov(f_d[calma].values.T)
D_e = np.diag(252 * res.var(axis=0, ddof=Xf.shape[1]))
def riesgo_factores(w, B, Sf, D):
x = B.T @ w # exposure to each factor
var = x @ Sf @ x + w @ D @ w
rc = x * (Sf @ x) / var # contribution of each factor
return pd.Series(np.r_[rc, w @ D @ w / var],
index=nombres + ["idiosyncratic"])
w = pd.Series(0.0, index=cols)
w[["RV_EEUU", "RF_T710"]] = [0.6, 0.4]
w_n = np.full(len(cols), 1 / len(cols))
pd.DataFrame({
"60/40_verdad": riesgo_factores(w.values, B, Sf, D),
"60/40_estim": riesgo_factores(w.values, B_e, Sf_e, D_e),
"1/N_verdad": riesgo_factores(w_n, B, Sf, D),
"1/N_estim": riesgo_factores(w_n, B_e, Sf_e, D_e),
}).round(3)max |BΣfB' + D − Σ_calm|: 5.0e-09
60/40_verdad 60/40_estim 1/N_verdad 1/N_estim
mercado 0.976 0.974 0.786 0.777
tamano -0.008 -0.006 0.007 0.006
valor 0.002 0.003 0.004 0.002
tipos -0.000 0.002 -0.011 -0.010
credito 0.000 -0.000 0.021 0.021
materias 0.000 0.000 0.107 0.114
dolar -0.000 -0.001 0.052 0.055
emergentes 0.000 -0.001 0.019 0.021
europa 0.000 0.000 0.001 0.000
japon 0.000 -0.000 0.003 0.002
idiosyncratic 0.030 0.029 0.011 0.011First, the check: with the published files reproduces the true calm covariance with an error of , that is, the rounding of the files. Then, the attribution: in the 60/40, the market factor explains 97.6% of the risk (truth) and 97.4% (estimate); rates, nothing: the portfolio is exposed to them, but their covariance with the portfolio is almost zero, because the factor's own variance is offset by its negative correlation with the market. In the 1/N of the 36 assets, the market explains 78.6%, commodities 10.7% and the dollar 5.2%; the estimates are within one point of the truth for every factor. With a well-specified model and 20 years of daily data, risk attribution is reliable. Return attribution, as seen with the alphas, not so much.
PCA: factors without names
The Fama-French factors are observed: portfolios built with an economic rule. Principal components are statistical factors: the directions of maximum variance of the returns themselves, orthogonal to each other, with no name and no fixed sign. They are computed with the eigenvalue decomposition of the correlation matrix (lesson 0.7.3); their use for estimating covariances with little data is in lesson 4.1.2. Here the question is a different one: are the components of a universe its factors?
Z = (ex_d - ex_d.mean()) / ex_d.std()
autoval, autovec = np.linalg.eigh(np.corrcoef(Z.values.T))
autoval, autovec = autoval[::-1], autovec[:, ::-1]
pcs = Z.values @ autovec[:, :10]
print("eigenvalues:", autoval[:6].round(2))
print("variance explained by 1, 3 and 10:",
(autoval.cumsum()[[0, 2, 9]] / autoval.sum()).round(3))
def r2(y, X):
X = np.column_stack([np.ones(len(X)), X])
e = y - X @ np.linalg.lstsq(X, y, rcond=None)[0]
return 1 - e.var() / y.var()
pd.DataFrame({k: [r2(f_d[f].values, pcs[:, :k]) for f in nombres]
for k in (1, 3, 5, 10)}, index=nombres).round(2)eigenvalues: [20.15 6.11 2.68 1.06 0.95 0.74]
variance explained by 1, 3 and 10: [0.56 0.804 0.931]
1 3 5 10
mercado 0.97 0.98 0.99 0.99
tamano 0.05 0.05 0.06 0.09
valor 0.00 0.06 0.08 0.78
tipos 0.05 0.96 0.98 0.98
credito 0.28 0.33 0.93 0.95
materias 0.21 0.85 0.89 0.93
dolar 0.16 0.35 0.65 0.91
emergentes 0.19 0.23 0.24 0.41
europa 0.00 0.00 0.01 0.03
japon 0.00 0.00 0.00 0.95The first component explains 56% of the variance of the 36 assets; the first three, 80%, and the first ten, 93%. Having ten true factors does not mean that the first ten components are those ten factors. The table gives the R² of the regression of each true factor on the first components:
- One component is already the market (R² 0.97). With three, rates (0.96) and commodities (0.85) appear.
- With five, credit (0.93); with ten, the dollar (0.91), Japan (0.95) and value (0.78).
- Size, emerging markets and Europe do not appear even with ten components (0.09, 0.41 and 0.03). The European factor, with 6% volatility, only loads on two assets (European equities hedged and unhedged); the Japanese one, with 9%, also on two, and it does appear. PCA ranks by explained variance: a narrow, low-volatility factor falls below combinations of idiosyncratic noise from other assets.
cargas_pc = pd.DataFrame(autovec[:, :3], index=cols,
columns=["pc1", "pc2", "pc3"])
for c in cargas_pc:
s = cargas_pc[c].sort_values()
print(c, "extremes:", ", ".join(
f"{k} {v:+.2f}" for k, v in pd.concat([s.head(2),
s.tail(2)]).items()))pc1 extremes: RF_T20 -0.05, RF_T710 -0.05, SC_IND +0.21, RV_EEUU +0.22
pc2 extremes: RF_T710 -0.38, RF_T37 -0.38, ES_VAL +0.05, SC_FIN +0.06
pc3 extremes: MP_AMP -0.48, MP_ENE -0.47, RF_CIG +0.10, RF_CHY +0.14The loadings of the first three components can be interpreted because the universe is simple: the first loads positively on all equity markets (market), the second loads almost only on Treasury bonds, all with the same sign (rates; the negative sign is arbitrary: a component and its opposite are the same) and the third on commodities. In a real universe the interpretation is harder and changes from one window to another; from the fourth component onward there is almost never a clean economic reading.
What each type of factor is for:
| Observed factors (Fama-French) | Principal components | |
|---|---|---|
| What they are | Portfolios with an economic rule | Directions of maximum variance |
| Interpretation | Direct (value, size…) | Only the first ones, and not always |
| Can be bought | Yes, approximately | Not as such |
| Used for | Evaluating managers, attributing return | Reducing dimension, estimating covariances (4.1.2), detecting regime changes |
| Risk | Leaving out a factor (the alpha absorbs it) | Overinterpreting the components |
Common mistakes
- Stopping at the CAPM. A CAPM alpha in a style fund is the style premium.
- Factors from another region or currency. US factors for a European fund put the exchange rate and the difference between markets into the alpha.
- Reading the t of one fund among many. Among 20 funds, one without alpha comes out with t > 2 (F18).
- Linear models for strategies with options or timing. The linear regression manufactures alpha (F12) or hides it (F13). For volatility-selling strategies, the alpha is an insurance premium.
- Fixed loadings for funds that change. F14 comes out with a style it never had and a spurious negative alpha.
- Interpreting every principal component. The first is usually the market; from there on, be careful.
- Log returns. The factor regression of a portfolio uses simple excess returns, like French's factors.
Exercises
- Repeat the six-factor regression of F08 with only the first ten years and with only the last ten. How many times does its alpha (true: 3%) come out significant? What does that say about evaluating a manager with ten years of data?
Solution
for a, b in [("2006-01", "2015-12"), ("2016-01", "2025-12")]:
m = regresion(exc["F08"].loc[a:b], fac[F6].loc[a:b])
print(f"{a[:4]}-{b[:4]}: alpha {1200 * m.params.iloc[0]:5.2f} %"
f" t {m.tvalues.iloc[0]:5.2f}")2006-2015: alpha 3.69 % t 2.42
2016-2025: alpha 2.47 % t 1.43With ten years, the standard error of the alpha grows by a factor of compared with twenty, and a real 3% alpha with 4.5% idiosyncratic volatility has an expected t of only . Roughly half the time it will not come out significant. In these data that is exactly what happens: in 2006-2015 the alpha comes out at 3.69% with t = 2.42 and in 2016-2025 at 2.47% with t = 1.43, which is not significant. Ten years of history are not enough to separate a good manager from a lucky one, and almost no manager has twenty with the same strategy.
- Estimate F13 with the CAPM and with the Henriksson-Merton model with the market only (without the other five factors) and compare the alpha. Does the conclusion change compared with the six-factor model?
Solution
capm13 = regresion(exc["F13"], fac[["mkt_rf"]])
hm13 = regresion(exc["F13"], X[["mkt_rf", "mkt_neg"]])
print(f"CAPM: alpha {1200 * capm13.params.iloc[0]:.2f} %, "
f"beta {capm13.params.iloc[1]:.2f}")
print(f"HM: alpha {1200 * hm13.params.iloc[0]:.2f} %, "
f"beta {hm13.params.iloc[1]:.2f}, "
f"extra {hm13.params.iloc[2]:.2f}")CAPM: alpha -1.19 %, beta 0.54
HM: alpha 5.79 %, beta 0.40, extra 0.24It does not change: F13 has no exposure to the other factors, so the market is enough. What decides is the nonlinear term. With the linear CAPM, the alpha is −1.19% and the beta, 0.54, sits between the normal one and the one in declines; with Henriksson-Merton, the alpha goes back to 5.79%, the normal beta to 0.40 and the extra one to 0.24, close to the truth. Choosing the factors matters; so does choosing the form of the model.
- No code: a manager of a put-selling strategy on an index presents a six-factor alpha of −0.5% and argues that “the strategy has no alpha, but it does not cost anything either, because its beta is only 0.5”. Use F13 to explain what the linear regression is hiding and what you would ask for to evaluate it.
Solution
The linear regression mixes two things that in F13 have opposite signs: a premium collected every month (the Henriksson-Merton alpha, 5.83% a year) and an extra loss in declines (an additional beta of 0.24, which costs 5.68% a year on average and concentrates the losses in the worst months: −18.5% when the market falls 28.9%). The beta of 0.5 is an average of 0.40 in rallies and 0.64 in declines: the exposure at the worst moment is higher than the one declared. I would ask for the regression with the term, the worst months of the strategy alongside those of the market, its maximum drawdown and a stress analysis (lesson 4.2.4) with market declines that are not in its history. The question is not whether it has alpha, but how much it loses when the market falls and whether someone is paying it enough to take that on.
Each answer is folded under its question
Self-assessment
F10 has a CAPM alpha of 1.45% a year and a six-factor alpha of −0.09%. Where did the CAPM alpha come from?
- AFrom the manager's skill, which the six-factor model cannot measure
- BFrom a data error
- CFrom exposures to premia that the CAPM does not include: profitability (RMW, +1.21 points), investment (CMA, +0.44) and momentum (+0.34), with HML subtracting 1.24
- DFrom the risk-free rate
Show answer
Answer C. From exposures to premia that the CAPM does not include: profitability (RMW, +1.21 points), investment (CMA, +0.44) and momentum (+0.34), with HML subtracting 1.24
The six-factor attribution splits the mean excess return of 8.25% into market (7.51), the other five factors and an alpha of −0.09. The fund collects premia that can be bought with a factor index: it is not alpha.
Among the 20 funds, seven have p < 0.05 on the six-factor alpha. With the Holm correction, only F12 and F16 remain. What happens to F08, which has a true alpha of 3%?
- AHolm has been applied wrongly
- BWith 20 years and its own idiosyncratic volatility, a 3% alpha gives a t of 2.86 and p = 0.0042: it does not pass the Holm threshold (0.05/20 = 0.0025 for the first). Controlling false positives costs power
- CF08 has no alpha
- DOrdinary errors should be used instead of Newey-West
Show answer
Answer B. With 20 years and its own idiosyncratic volatility, a 3% alpha gives a t of 2.86 and p = 0.0042: it does not pass the Holm threshold (0.05/20 = 0.0025 for the first). Controlling false positives costs power
Holm controls the probability of any false positive among the 20; in doing so it loses funds with real alpha. Benjamini-Hochberg (FDR) does accept F08 and F09, but also the false positive F18. No correction comes without a cost.
F12 has a linear alpha of 3.13% with t = 4.15, the most significant of all, and a true alpha of zero. Why?
- ABecause it does market timing: its return carries a term 0.5·mkt_rf², always positive, that the linear regression puts into the alpha. With the Treynor-Mazuy quadratic term the alpha drops to 0.68% and the coefficient comes out at 0.44
- BBy chance
- CBecause its beta is low
- DBecause Newey-West errors inflate the t
Show answer
Answer A. Because it does market timing: its return carries a term 0.5·mkt_rf², always positive, that the linear regression puts into the alpha. With the Treynor-Mazuy quadratic term the alpha drops to 0.68% and the coefficient comes out at 0.44
The mean of mkt_rf² is positive; in a linear regression it turns into a constant. The correct model is nonlinear, and the significance of the linear alpha says nothing about skill.
F13 sells puts: a true alpha of 6% and an extra beta of 0.25 when the market falls. What alpha does the linear regression give and why?
- A6%, because the regression is unbiased
- BZero
- C12%
- D−1.21%: the term 0.25·min(mkt_rf, 0) has a negative mean and the linear regression absorbs it between the alpha and the beta. With the Henriksson-Merton term the alpha comes out at 5.83%
Show answer
Answer D. −1.21%: the term 0.25·min(mkt_rf, 0) has a negative mean and the linear regression absorbs it between the alpha and the beta. With the Henriksson-Merton term the alpha comes out at 5.83%
The 6% is the premium it collects for selling insurance; the mean of 0.24·min(mkt_rf, 0) costs it 5.68% a year. What it earns in calm months it gives back in falling months (−18.5% in the worst month). Separating the two terms requires the nonlinear model.
PCA on the 36 assets of the universe recovers the market factor with a single component (R² 0.97) and does not recover the European factor even with ten (R² 0.03). Why?
- ABecause the European factor does not exist
- BBecause PCA needs monthly data
- CBecause PCA ranks by variance: the market moves almost every asset and dominates, while the European factor, with 6% volatility, only loads on two assets and its effect gets confused with other combinations
- DBecause principal components are always the Fama-French factors
Show answer
Answer C. Because PCA ranks by variance: the market moves almost every asset and dominates, while the European factor, with 6% volatility, only loads on two assets and its effect gets confused with other combinations
The components are the directions of maximum variance, orthogonal to each other, not the economic factors. They recover the large, broad factors well (market, rates, commodities) and the narrow ones badly (Europe, size, emerging markets).
Key points
- A time-series regression of a portfolio's excess returns on the factors gives its alpha (the constant), its loadings and its R². With 240 months, the loadings of the 17 linear funds are recovered with a mean error of 0.01 to 0.04.
- A CAPM alpha can be an exposure to other factors: that of F10 (1.45%) is profitability, investment and momentum, and it disappears with six factors.
- Among 20 funds without correction, a fund with no alpha comes out significant (F18, t = 2.55). Holm removes it but also removes real alphas; Benjamini-Hochberg keeps the real ones and the false one.
- Nonlinear strategies fool the linear regression: market timing looks like alpha (F12) and put selling looks like it has none (F13). mkt_rf² or min(mkt_rf, 0) has to be added.
- A loading that is stable over the whole sample can be the average of two styles (F14: 0.22 versus 0.85 and −0.35 by halves); a 36-month rolling window takes almost three years to see it.
- PCA recovers the large, broad factors (market, rates, commodities) and not the narrow ones; its components are not economic factors but directions of variance.
Resources
Documentation
Books
- Sharpe, W. F. (1964): Capital Asset Prices: A Theory of Market Equilibrium under Conditions of Risk. The Journal of Finance 19(3), 425-442
- Fama, E. F. and French, K. R. (1993): Common risk factors in the returns on stocks and bonds. Journal of Financial Economics 33(1), 3-56
- Fama, E. F. and French, K. R. (2015): A five-factor asset pricing model. Journal of Financial Economics 116(1), 1-22
- Carhart, M. M. (1997): On Persistence in Mutual Fund Performance. The Journal of Finance 52(1), 57-82
- Henriksson, R. D. and Merton, R. C. (1981): On Market Timing and Investment Performance. II. Statistical Procedures for Evaluating Forecasting Skills. The Journal of Business 54(4)