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.

Fig. — True alpha and alpha measured with six factors for the 20 synthetic funds, with their 95% interval (Newey-West errors), 2006-2025
Level 4 lessons
  1. 4.1 · Portfolio theory and optimisation

    1. 4.1.1 Markowitz: efficient frontier and real constraints
    2. 4.1.2 Covariance estimation: shrinkage, factor models and denoising
    3. 4.1.3 Black-Litterman: from market equilibrium to views
    4. 4.1.4 Risk parity, minimum variance, HRP and HERC
    5. 4.1.5 Optimization with CVaR, drawdown, turnover and costs
    6. 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
    7. 4.1.7 Rebalancing and strategy portfolios
    8. 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
  2. 4.2 · Risk management and position sizing

    1. 4.2.1 Risk per trade, stops and risk of ruin
    2. 4.2.2 Kelly criterion and volatility targeting: how much to risk
    3. 4.2.3 VaR, Expected Shortfall and VaR backtesting
    4. 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
    5. 4.2.5 Drawdown control and kill switch
    6. 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
  3. 4.3 · Microstructure and execution

    1. 4.3.1 Implementation shortfall, VWAP, TWAP and Almgren-Chriss
    2. 4.3.2 Order flow imbalance, microstructure signals and latency
  4. 4.4 · Brokers and APIs

    1. 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
    2. 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
    4. 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
    5. 4.4.5 Secure credentials: environment variables, secret managers and least privilege
  5. 4.5 · Live trading system

    1. 4.5.1 Live system architecture: from data to reconciliation
    2. 4.5.2 Order management: states, partial fills, cancellations and reconnections
    3. 4.5.3 Paper trading, shadow trading and slippage monitoring
    4. 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
    5. 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
    6. 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

rt−rf,t=α+β (rm,t−rf,t)+εtr_t - r_{f,t} = \alpha + \beta\,(r_{m,t} - r_{f,t}) + \varepsilon_t

separates what the portfolio earns by being exposed to the market (β\beta) from what it earns on its own (α\alpha). 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:

rt−rf,t=α+βm MKTt+s SMBt+h HMLt+r RMWt+c CMAt+m MOMt+εt.r_t - r_{f,t} = \alpha + \beta_m\,MKT_t + s\,SMB_t + h\,HML_t + r\,RMW_t + c\,CMA_t + m\,MOM_t + \varepsilon_t.
CAPM: marketFF3: + SMB, HMLCarhart: + MOMFF5: + RMW, CMASix factors
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.

IN [01]python
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)
OUT [01]Factors and funds
(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.23

The 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:

IN [02]python
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)
OUT [02]The format of Kenneth French's library
         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.0041

With 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:

IN [03]python
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.

IN [04]python
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)
OUT [04]CAPM and six factors for the 20 funds
     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.94

The 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:

IN [05]python
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)
OUT [05]Measured loadings versus true loadings
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: float64

In 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):

r−rf‾=α^+∑kb^k fk‾.\overline{r - r_f} = \hat\alpha + \sum_k \hat b_k\, \overline{f_k}.

F10 is the textbook case: a CAPM alpha of 1.45%.

IN [06]python
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)
OUT [06]F10: the CAPM alpha was factors
        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.25

The 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:

IN [07]python
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)
OUT [07]Twenty tests at once
     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    True

Seven funds have p < 0.05. With the truth known:

FundTrue alphapWhat is it?HolmBH
F1200.0000Market timing: spurious alpha from a misspecified modelyesyes
F16−2.5%0.0016High fee: real negative alphayesyes
F083%0.0042Real alphanoyes
F091.5%0.0102Small real alpha, measured with lucknoyes
F1800.0106False positivenoyes
F112%0.0407Real alphanono
F1400.0474Style drift: spurious alphanono

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.

yesnoyesnonoyesAlpha with t 2Many funds?Holm or BHNonlinear?Add TM or HMStable loading?Rolling windowsCredible alpha
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: r−rf=α+β MKT+γ MKT2+…r - r_f = \alpha + \beta\,MKT + \gamma\,MKT^2 + \dots. Truth: γ=0.5\gamma = 0.5, α=0\alpha = 0.
  • 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 min⁡(MKT,0)\min(MKT, 0) term: a different beta in declines. Truth: α=6%\alpha = 6\% a year (the premium), normal beta 0.4 and an extra beta of 0.25 in declines.
IN [08]python
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)
OUT [08]F12 and F13: market timing and put selling
                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.24

The linear model gets both wrong, and in opposite directions. To F12 it attributes 3.13% of alpha: the mean of MKT2MKT^2 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 γ\gamma 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:

IN [09]python
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)
OUT [09]What F13 collects and what it costs
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.54
Fig. — Mean monthly return of fund F13, which sells puts, by market bins, with the Henriksson-Merton fit (2006-2025)

The 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:

IN [10]python
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)
OUT [10]F14: the style changes in 2016
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: float64
Fig. — Value (HML) loading of fund F14, which changes style in January 2016: 36-month rolling regression against the truth

With 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 Σ=B Σf B′+D\Sigma = B\,\Sigma_f\,B' + D, the variance of a portfolio ww is x′Σfx+w′Dwx'\Sigma_f x + w'Dw with x=B′wx = B'w, the portfolio's exposure to each factor. By Euler, as in lesson 4.1.4, each factor contributes xk(Σfx)k/σp2x_k(\Sigma_f x)_k / \sigma_p^2 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:

IN [11]python
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)
OUT [11]Portfolio risk by factor: truth and estimate
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.011

First, the check: B Σf B′+DB\,\Sigma_f\,B' + D with the published files reproduces the true calm covariance with an error of 5⋅10−95 \cdot 10^{-9}, 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?

IN [12]python
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)
OUT [12]Principal components of the universe
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.95

The 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 kk components:

Fig. — How much of each true factor of the universe the principal components of the 36 assets recover (2006-2025)
  • 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.
IN [13]python
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()))
OUT [13]Which assets load on each component
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.14

The 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 arePortfolios with an economic ruleDirections of maximum variance
InterpretationDirect (value, size…)Only the first ones, and not always
Can be boughtYes, approximatelyNot as such
Used forEvaluating managers, attributing returnReducing dimension, estimating covariances (4.1.2), detecting regime changes
RiskLeaving 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

  1. 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
IN [14]python
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}")
OUT [14]F08's alpha by decade
2006-2015: alpha  3.69 %  t  2.42
2016-2025: alpha  2.47 %  t  1.43

With ten years, the standard error of the alpha grows by a factor of 2\sqrt{2} compared with twenty, and a real 3% alpha with 4.5% idiosyncratic volatility has an expected t of only 3/(4.5/10)≈2.13 / (4.5/\sqrt{10}) \approx 2.1. 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.

  1. 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
IN [15]python
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}")
OUT [15]Henriksson-Merton with a single factor
CAPM: alpha -1.19 %, beta 0.54
HM:   alpha 5.79 %, beta 0.40, extra 0.24

It 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.

  1. 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 min⁡(MKT,0)\min(MKT, 0) 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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