4  Asset pricing econometrics

This chapter studies asset pricing as an applied econometric problem. The goal is to connect factor-model logic, mathematical notation, R code, and empirical interpretation in one continuous workflow. The chapter starts with the economic question, builds the data and test assets, estimates time-series regressions, uses cross-sectional regressions to price factor exposures, and then rewrites the same pricing problem with stochastic discount factor GMM. The emphasis is on making each equation operational: what it means, why it is estimated, how it appears in the code, and how its results should be read.

4.1 Asset pricing as an econometric problem

Asset pricing econometrics asks a precise empirical question: can a small set of systematic risk factors explain why some assets or portfolios earn higher average excess returns than others? The answer is estimated from data, but the data work has to stay connected to the economic model. A coefficient is useful only when the reader can say what it measures, how the code estimated it, and what its value implies for the model.

This chapter follows the regression-based logic used in empirical asset pricing (Cochrane 2005). The same idea appears in three connected forms. Time-series regressions estimate each asset’s exposure to common factors. Cross-sectional regressions ask whether those exposures explain average excess returns across test assets. Stochastic discount factor moments express the same restriction as a pricing condition: after discounting by the model, excess returns should have zero remaining average payoff.

The empirical examples use one mutual fund and the 25 Fama-French size-value portfolios as test assets. The risk factors are the market excess return, size, value, momentum, profitability, and investment factors from the Kenneth French Data Library. The chapter downloads the data during execution and fixes the sample end at 2020-12-31 so the empirical window is reproducible.

The central object is an excess return:

\[ R_{i,t}^e = R_{i,t}-R_{f,t}, \]

where \(R_{i,t}\) is the return on asset \(i\) in month \(t\) and \(R_{f,t}\) is the risk-free return. A factor model explains \(R_{i,t}^e\) using common sources of variation. The CAPM uses one factor, the market excess return (Sharpe 1964; Lintner 1965; Mossin 1966). The Fama-French three-factor model adds size and value factors (Fama and French 1992, 1993). Momentum and the five-factor model extend the factor set (Carhart 1997; Fama and French 2015).

The workflow is:

\[ \text{factor returns} \longrightarrow \text{test assets} \longrightarrow \text{time-series regressions} \longrightarrow \text{alphas and betas} \longrightarrow \text{cross-sectional pricing} \longrightarrow \text{pricing errors}. \]

The chapter should be read as a sequence of empirical questions. The data tables verify samples, dates, and units. The factor figures show what the explanatory portfolios look like before they become regressors. The time-series regressions translate co-movement into alphas and betas. The cross-sectional regressions then ask whether those estimated exposures, or equivalent return-factor moments, explain average excess returns across the 25 portfolios. The final diagnostic reads the remaining pricing errors by size and value characteristics. Each table and figure therefore has a specific role: audit the data, describe the factors, estimate exposures, diagnose the first pass, test the asset-pricing restriction, or interpret what the model leaves unexplained.

The main vocabulary is:

Concept Symbol Reading in the chapter
Test asset \(i\) An asset or portfolio whose average excess return the model tries to explain.
Excess return \(R_{i,t}^e\) The asset return after subtracting the risk-free rate.
Factor \(f_{j,t}\) A common return series used to represent a source of systematic risk.
Beta \(\beta_{i,j}\) The exposure of asset \(i\) to factor \(j\), estimated from a time-series regression.
Alpha \(\alpha_i\) The average excess return left over after the factor exposures are used.
Price of risk \(\lambda_j\) The cross-sectional reward attached to one unit of exposure to factor \(j\).
Pricing error \(\eta_i\) or \(\alpha_i\) The part of average return that the model leaves unexplained in the relevant test.
Stochastic discount factor \(m_t\) A random discount factor that prices excess returns through moment restrictions.

The chapter uses these concepts in a fixed order. First define excess returns. Then estimate betas and alphas from time-series regressions. Then estimate prices of risk from a cross-section of average returns. Finally inspect pricing errors to see where the model still struggles.

This ordering is useful because asset pricing notation can compress several ideas into one equation. The economic question, the statistical object, and the code object should be separated before they are joined again:

Question Mathematical object Code object Interpretation
What is being explained? \(\bar{R}_i^e\) ERe_percent Average excess return of each test asset.
What is the proposed source of explanation? \(\mathbf{f}_t\) Mkt_RF, SMB3, HML, Mom, RMW, CMA Common factor returns.
How exposed is each asset? \(\boldsymbol{\beta}_i\) Time-series lm() coefficients Sensitivity of asset returns to factor returns.
What does the model leave behind? \(\alpha_i\) or \(\eta_i\) Intercepts and fitted-value gaps Pricing errors.
How is risk priced across assets? \(\boldsymbol{\lambda}\) Cross-sectional lm() coefficients Reward attached to factor exposure.
How does the SDF view express the same restriction? \(E(m_tR_{i,t}^e)=0\) Return-factor moments Discounted excess returns should average to zero.

The word “pricing” should also be read carefully. In this chapter, pricing means testing whether a model can explain average excess returns. For an excess return, the initial cost is zero because the investment is financed at the risk-free rate. A correct model should assign zero value to that excess-return payoff after applying the relevant risk adjustment. That is why alphas, fitted-value gaps, and SDF moments all become ways of measuring pricing errors.

4.2 Data, downloads, and sample construction

The factor data and the 25 size-value portfolio returns are downloaded from the Kenneth French Data Library (French 2026). The individual test asset example uses the Fidelity Contrafund ticker FCNTX downloaded with tidyquant. All returns are converted from percentages to decimal units during estimation. Percent units are used only when a table or graph is meant for interpretation.

Code
sample_end <- as.Date("2020-12-31")

ff_urls <- list(
  ff3 = "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Research_Data_Factors_CSV.zip",
  momentum = "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Momentum_Factor_CSV.zip",
  ff5 = "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Research_Data_5_Factors_2x3_CSV.zip",
  portfolios_25 = "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/25_Portfolios_5x5_CSV.zip"
)

download_ff_csv <- function(url, skip) {
  zip_file <- tempfile(fileext = ".zip")
  unzip_dir <- tempfile()
  dir.create(unzip_dir)

  download_ok <- tryCatch(
    {
      utils::download.file(url, zip_file, mode = "wb", quiet = TRUE, method = "libcurl")
      TRUE
    },
    error = function(error) FALSE,
    warning = function(warning) FALSE
  )

  if (!download_ok && nzchar(Sys.which("curl.exe"))) {
    status <- system2(
      Sys.which("curl.exe"),
      c("-L", "-s", "-o", zip_file, url),
      stdout = TRUE,
      stderr = TRUE
    )
    download_ok <- file.exists(zip_file) && file.info(zip_file)$size > 0
  }

  if (!download_ok) {
    stop("Could not download Fama-French data from: ", url, call. = FALSE)
  }

  utils::unzip(zip_file, exdir = unzip_dir)

  csv_file <- list.files(unzip_dir, pattern = "\\.csv$|\\.CSV$", full.names = TRUE)

  if (!length(csv_file)) {
    stop("No CSV file found in downloaded archive: ", url, call. = FALSE)
  }

  readr::read_csv(csv_file[[1]], skip = skip, show_col_types = FALSE, progress = FALSE)
}

parse_ff_monthly <- function(raw_data) {
  names(raw_data)[1] <- "date_raw"

  date_text <- trimws(as.character(raw_data$date_raw))
  first_blank <- which(is.na(raw_data$date_raw) | date_text == "")[1]

  if (!is.na(first_blank) && first_blank > 1) {
    raw_data <- raw_data[seq_len(first_blank - 1), ]
  }

  raw_data |>
    mutate(
      date_raw = trimws(as.character(date_raw)),
      date = lubridate::ceiling_date(lubridate::ymd(paste0(date_raw, "01")), "month") -
        lubridate::days(1)
    ) |>
    select(date, everything(), -date_raw) |>
    mutate(across(-date, ~ as.numeric(.x) / 100)) |>
    rename_with(~ gsub("-", "_", .x, fixed = TRUE))
}

first_last_rows <- function(data, n = 3) {
  idx <- unique(c(seq_len(min(n, nrow(data))), seq(max(nrow(data) - n + 1, 1), nrow(data))))
  data[idx, , drop = FALSE]
}

ff3 <- download_ff_csv(ff_urls$ff3, skip = 3) |>
  parse_ff_monthly() |>
  filter(date <= sample_end)

momentum <- download_ff_csv(ff_urls$momentum, skip = 13) |>
  parse_ff_monthly() |>
  filter(date <= sample_end)

ff5 <- download_ff_csv(ff_urls$ff5, skip = 3) |>
  parse_ff_monthly() |>
  filter(date <= sample_end, !is.na(RMW), !is.na(CMA))

portfolios_25 <- download_ff_csv(ff_urls$portfolios_25, skip = 15) |>
  parse_ff_monthly() |>
  filter(date <= sample_end)

ff3_momentum <- ff3 |>
  inner_join(momentum, by = "date") |>
  rename(SMB3 = SMB) |>
  relocate(RF, .after = Mom)

ff5_extra <- ff5 |>
  select(date, SMB, RMW, CMA) |>
  rename(SMB5 = SMB)

all_factors <- ff3_momentum |>
  inner_join(ff5_extra, by = "date") |>
  relocate(RF, .after = CMA)

factor_windows <- bind_rows(
  ff3 |> summarise(source = "Fama-French 3 factors", start = min(date), end = max(date), months = n()),
  momentum |> summarise(source = "Momentum factor", start = min(date), end = max(date), months = n()),
  ff5 |> summarise(source = "Fama-French 5 factors", start = min(date), end = max(date), months = n()),
  portfolios_25 |> summarise(source = "25 size-value portfolios", start = min(date), end = max(date), months = n())
)

The code keeps the data in decimal form. For example, a monthly return of 0.01 means one percent. This convention prevents unit mistakes when returns are added, multiplied, or used in regressions.

The first data audit shows the opening and closing observations of the main data objects. These checks verify dates, decimal units, and column names before any model is estimated.

Code
factor_windows |>
  mutate(across(c(start, end), as.character)) |>
  kable(
    caption = "Sample windows of the downloaded Fama-French data files.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Sample windows of the downloaded Fama-French data files.
source start end months
Fama-French 3 factors 1926-07-31 2020-12-31 1134
Momentum factor 1927-01-31 2020-12-31 1128
Fama-French 5 factors 1963-07-31 2020-12-31 690
25 size-value portfolios 1926-07-31 2020-12-31 1134
Code
first_last_rows(ff3_momentum) |>
  select(date, Mkt_RF, SMB3, HML, Mom, RF) |>
  kable(
    caption = "First and last observations of the joined FF3 and momentum sample.",
    digits = 6,
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
First and last observations of the joined FF3 and momentum sample.
date Mkt_RF SMB3 HML Mom RF
1927-01-31 -0.0005 -0.0032 0.0458 0.0057 0.0025
1927-02-28 0.0417 0.0007 0.0272 -0.0150 0.0026
1927-03-31 0.0014 -0.0177 -0.0238 0.0352 0.0030
2020-10-31 -0.0208 0.0434 0.0425 -0.0314 0.0001
2020-11-30 0.1245 0.0581 0.0209 -0.1255 0.0001
2020-12-31 0.0463 0.0501 -0.0168 -0.0237 0.0001
Code
first_last_rows(ff5) |>
  kable(
    caption = "First and last observations of the Fama-French five-factor data.",
    digits = 6,
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
First and last observations of the Fama-French five-factor data.
date Mkt_RF SMB HML RMW CMA RF
1963-07-31 -0.0039 -0.0048 -0.0081 0.0064 -0.0115 0.0027
1963-08-31 0.0508 -0.0080 0.0170 0.0040 -0.0038 0.0025
1963-09-30 -0.0157 -0.0043 0.0000 -0.0078 0.0015 0.0027
2020-10-31 -0.0208 0.0467 0.0425 -0.0077 -0.0057 0.0001
2020-11-30 0.1244 0.0712 0.0209 -0.0225 0.0128 0.0001
2020-12-31 0.0463 0.0493 -0.0168 -0.0197 -0.0006 0.0001
Code
first_last_rows(portfolios_25) |>
  select(date, `SMALL LoBM`, `SMALL HiBM`, `BIG LoBM`, `BIG HiBM`) |>
  kable(
    caption = "First and last observations of selected 25-portfolio corner returns.",
    digits = 6,
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
First and last observations of selected 25-portfolio corner returns.
date SMALL LoBM SMALL HiBM BIG LoBM BIG HiBM
1926-07-31 0.058276 0.019583 0.033248 0.005623
1926-08-31 -0.020206 0.085104 0.010169 0.077576
1926-09-30 -0.048291 0.008586 -0.012951 -0.024284
2020-10-31 -0.026753 0.012595 -0.045743 0.009898
2020-11-30 0.257600 0.245286 0.107281 0.219976
2020-12-31 0.125156 0.074510 0.053002 0.080558

These tables are a data audit. The window table shows which raw files can be used over which dates. The joined FF3-momentum table then shows the actual intersection used when the model includes momentum, so every displayed column has an observed value for the same month. The first and last rows also confirm that the factor files and the portfolio file are monthly series, expressed in decimal units, and aligned on month-end dates. The selected corner portfolios already hint at the empirical structure used later: small growth, small value, big growth, and big value are the extreme cells of the size-value grid.

4.3 Excess returns and factor notation

The notation in this chapter has three indices in the background. The subscript \(i\) identifies the asset or portfolio. The subscript \(t\) identifies the month. The subscript attached to a beta, such as \(M\), \(S\), or \(H\), identifies the factor. For example, \(\beta_{i,H}\) is the value-factor exposure of asset \(i\), estimated from monthly observations over time.

Each model has an observed left-hand side and estimated right-hand-side coefficients. The observed variable is always an excess return, \(R_{i,t}^e\). The factors are also observed return series. The unknown objects are the intercept and the betas. The regression estimates those unknowns by choosing the line, or plane, that best summarizes the historical relation between the asset’s excess return and the factor returns.

The CAPM time-series regression for asset \(i\) is

\[ R_{i,t}^e = \alpha_i + \beta_{i,M} MKT_t + \varepsilon_{i,t}, \]

where \(MKT_t\) is the market excess return. The intercept \(\alpha_i\) is the average excess return that remains after controlling for market exposure. In a well-specified asset pricing model, alphas should be economically small.

The CAPM is therefore a one-factor claim. It says that market exposure is the relevant systematic risk for expected returns. The regression version asks how strongly each asset moves with the market. The asset-pricing version asks whether that market exposure is enough to account for the asset’s average excess return.

The Fama-French three-factor model is

\[ R_{i,t}^e = \alpha_i + \beta_{i,M} MKT_t + \beta_{i,S} SMB_t + \beta_{i,H} HML_t + \varepsilon_{i,t}. \]

The factors have direct economic interpretations:

Factor Interpretation
\(MKT_t\) Market excess return.
\(SMB_t\) Size factor: small minus big.
\(HML_t\) Value factor: high book-to-market minus low book-to-market.
\(MOM_t\) Momentum factor: recent winners minus recent losers.
\(RMW_t\) Profitability factor: robust minus weak profitability.
\(CMA_t\) Investment factor: conservative minus aggressive investment.

Each factor is constructed as a portfolio return. This point is central for the interpretation of betas. A positive loading on \(SMB_t\) means that the asset tends to behave like the small-minus-big portfolio. A positive loading on \(HML_t\) means that the asset tends to behave like the high-book-to-market minus low-book-to-market portfolio. The factor return series already encode the small and value portfolio strategies used in the regression.

The five-factor model replaces the three-factor specification with

\[ R_{i,t}^e = \alpha_i + \beta_{i,M} MKT_t + \beta_{i,S} SMB_t + \beta_{i,H} HML_t + \beta_{i,R} RMW_t + \beta_{i,C} CMA_t + \varepsilon_{i,t}. \]

The code names these variables as Mkt_RF, SMB3, HML, Mom, SMB5, RMW, and CMA. The distinction between SMB3 and SMB5 is intentional: the size factor in the three-factor data and the size factor in the five-factor data are constructed from different portfolio sorts.

The notation and the code follow the same structure. In the equation, \(R_{i,t}^e\) is the dependent variable and the factors are regressors. In R, Re is the dependent variable and the factor columns appear on the right side of the formula. For example, Re ~ Mkt_RF + SMB3 + HML is the code version of the Fama-French three-factor regression.

4.4 Factor portfolios through time

Before estimating asset pricing regressions, it is useful to inspect the factor returns themselves. The next calculation computes annualized return, annualized volatility, and a simple return-to-risk ratio.

This descriptive step has a practical purpose. A factor is a return series, so it can be read like a portfolio before it is used as a regressor. The annualized table summarizes reward and risk. The cumulative plots show the path by which those rewards are earned over time. The density plots show the distribution of monthly outcomes. The risk-return plots then compress those summaries into a single visual comparison. Together, these checks tell the reader what economic objects enter the regressions.

Code
annualized_performance <- function(data, cols) {
  data |>
    select(date, all_of(cols)) |>
    pivot_longer(-date, names_to = "factor", values_to = "return") |>
    filter(!is.na(return)) |>
    group_by(factor) |>
    summarise(
      months = n(),
      annualized_return = prod(1 + return)^(12 / months) - 1,
      annualized_volatility = sd(return) * sqrt(12),
      return_per_unit_risk = annualized_return / annualized_volatility,
      .groups = "drop"
    ) |>
    arrange(return_per_unit_risk)
}

order_series_by_final_value <- function(data, series_col, value_col = "cumulative_return") {
  final_values <- data |>
    group_by(.data[[series_col]]) |>
    summarise(final_value = dplyr::last(.data[[value_col]]), .groups = "drop") |>
    arrange(desc(final_value))

  final_order <- final_values[[series_col]]

  data[[series_col]] <- factor(data[[series_col]], levels = final_order)
  data
}

factor_perf <- annualized_performance(ff3_momentum, c("Mkt_RF", "SMB3", "HML", "Mom"))

factor_perf |>
  mutate(across(c(annualized_return, annualized_volatility, return_per_unit_risk), ~ round(.x, 4))) |>
  kable(
    caption = "Annualized performance of the three Fama-French factors and momentum.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Annualized performance of the three Fama-French factors and momentum.
factor months annualized_return annualized_volatility return_per_unit_risk
SMB3 1128 0.0189 0.1097 0.1719
HML 1128 0.0330 0.1220 0.2708
Mkt_RF 1128 0.0662 0.1858 0.3561
Mom 1128 0.0621 0.1638 0.3794

The annualized table turns each factor into an object that can be compared like a portfolio. The return column shows the long-run reward, the volatility column shows the risk of the factor strategy, and the return-per-risk column gives a compact scale for ranking factor performance before looking at individual assets.

The three-factor file is inspected first by itself. This mirrors the original workflow: read the Fama-French factors, check their time path, and then add momentum as an extension.

Code
ff3 |>
  select(date, Mkt_RF, SMB, HML, RF) |>
  pivot_longer(-date, names_to = "factor", values_to = "return") |>
  group_by(factor) |>
  mutate(cumulative_return = cumprod(1 + return)) |>
  ungroup() |>
  order_series_by_final_value("factor") |>
  ggplot(aes(date, cumulative_return, color = factor)) +
  geom_line(linewidth = 0.8) +
  scale_y_log10(labels = scales::number_format(accuracy = 0.1)) +
  labs(x = "Date", y = "Cumulative return, log scale", color = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "right")
Figure 4.1: Cumulative returns of the Fama-French three factors.

The cumulative-return plot emphasizes that factor premia are earned through time, with long expansions and reversals. A factor can have a positive average return and still experience long drawdowns, so the time path is part of the economic story.

Code
ff3 |>
  select(date, Mkt_RF, SMB, HML) |>
  pivot_longer(-date, names_to = "factor", values_to = "return") |>
  ggplot(aes(return, fill = factor)) +
  geom_density(alpha = 0.45, adjust = 2.5) +
  geom_vline(xintercept = 0, linetype = 2, color = "gray30") +
  coord_cartesian(xlim = c(-0.2, 0.2)) +
  labs(x = "Monthly return", y = "Density", fill = NULL) +
  scale_x_continuous(labels = scales::percent) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Figure 4.2: Distribution of monthly returns for the Fama-French three factors.

The density plot complements the cumulative plot. It shows where monthly returns are concentrated and how far negative or positive observations can extend. This helps students read factor performance as both a distribution of monthly outcomes and an annualized average.

Code
factor_perf |>
  ggplot(aes(annualized_volatility, annualized_return, color = return_per_unit_risk)) +
  geom_hline(yintercept = 0, color = "gray70", linewidth = 0.4) +
  geom_point(size = 3, show.legend = FALSE) +
  geom_text(aes(label = factor), vjust = -0.9, color = "black", size = 3.5) +
  labs(
    x = "Annualized volatility",
    y = "Annualized return",
    color = "Return per unit of risk"
  ) +
  scale_x_continuous(labels = scales::percent, expand = expansion(mult = c(0.08, 0.16))) +
  scale_y_continuous(labels = scales::percent, expand = expansion(mult = c(0.08, 0.22))) +
  scale_color_viridis_c(option = "C") +
  coord_cartesian(clip = "off") +
  theme_minimal(base_size = 12) +
  theme(
    legend.position = "none",
    plot.margin = margin(10, 34, 10, 10)
  )
Figure 4.3: Risk-return profile of the factor portfolios.

The risk-return profile compresses the table into one visual comparison. Points higher on the vertical axis have larger annualized returns, while points farther right have higher annualized volatility. Labels make clear which factor drives each position, so the graph can be read as a map of factor rewards and risks.

Cumulative factor returns show that factors are portfolios through time. The line for each factor is the wealth path from investing one dollar in that long-short factor portfolio:

\[ W_{j,t} = \prod_{s=1}^{t} (1+F_{j,s}). \]

Code
ff3_momentum |>
  select(date, Mkt_RF, SMB3, HML, Mom) |>
  pivot_longer(-date, names_to = "factor", values_to = "return") |>
  group_by(factor) |>
  mutate(cumulative_return = cumprod(1 + return)) |>
  ungroup() |>
  order_series_by_final_value("factor") |>
  ggplot(aes(date, cumulative_return, color = factor)) +
  geom_line(linewidth = 0.8) +
  scale_y_log10(labels = scales::number_format(accuracy = 0.1)) +
  labs(x = "Date", y = "Cumulative return, log scale", color = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "right")
Figure 4.4: Cumulative returns of Fama-French factors and momentum.

Adding momentum changes the visual comparison because the set of candidate factor portfolios is now broader. The purpose of the figure is to show how the market, size, value, and momentum factor histories differ before any asset pricing regression uses them as explanatory variables.

The distribution of monthly factor returns is another diagnostic. Momentum and value can have long episodes of strong performance and sharp reversals, so a factor’s average return should always be read together with its volatility and tail behavior.

Code
ff3_momentum |>
  select(date, Mkt_RF, SMB3, HML, Mom) |>
  pivot_longer(-date, names_to = "factor", values_to = "return") |>
  ggplot(aes(return, fill = factor)) +
  geom_density(alpha = 0.45, adjust = 2.5) +
  geom_vline(xintercept = 0, linetype = 2, color = "gray30") +
  coord_cartesian(xlim = c(-0.2, 0.2)) +
  labs(x = "Monthly return", y = "Density", fill = NULL) +
  scale_x_continuous(labels = scales::percent) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Figure 4.5: Distribution of monthly factor returns.

The monthly distributions also show why factor regressions can be sensitive to large episodes. A factor with a similar average return can have a different tail profile, and that affects how individual assets load on it in a regression.

The same workflow can be applied to the five-factor data. The shorter sample starts in 1963, because the profitability and investment factors are available from that point in the downloaded file.

Code
ff5_perf <- annualized_performance(ff5, c("Mkt_RF", "SMB", "HML", "RMW", "CMA"))

ff5_perf |>
  mutate(across(c(annualized_return, annualized_volatility, return_per_unit_risk), ~ round(.x, 4))) |>
  kable(
    caption = "Annualized performance of the Fama-French five factors.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Annualized performance of the Fama-French five factors.
factor months annualized_return annualized_volatility return_per_unit_risk
SMB 690 0.0222 0.1046 0.2128
HML 690 0.0259 0.0982 0.2636
RMW 690 0.0266 0.0754 0.3534
Mkt_RF 690 0.0578 0.1547 0.3740
CMA 690 0.0283 0.0678 0.4169

The five-factor table prepares the reader for a different factor set and a shorter historical window. Profitability and investment are interpreted as additional dimensions of systematic risk, so their return and volatility should be inspected before they are added to any regression.

The workflow also inspects the five-factor sample and the combined factor set with momentum. The samples differ: the five-factor data start later, and the SMB factor in the five-factor file is a different constructed series from the SMB factor in the three-factor file. The chapter keeps the variable names SMB3 and SMB5 to make that distinction visible in the code.

Code
all_factor_perf <- annualized_performance(
  all_factors,
  c("Mkt_RF", "SMB3", "HML", "Mom", "SMB5", "RMW", "CMA")
)

all_factor_perf |>
  mutate(across(c(annualized_return, annualized_volatility, return_per_unit_risk), ~ round(.x, 4))) |>
  kable(
    caption = "Annualized performance of the combined factor set.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Annualized performance of the combined factor set.
factor months annualized_return annualized_volatility return_per_unit_risk
SMB3 690 0.0186 0.1048 0.1772
SMB5 690 0.0222 0.1046 0.2128
HML 690 0.0259 0.0982 0.2636
RMW 690 0.0266 0.0754 0.3534
Mkt_RF 690 0.0578 0.1547 0.3740
CMA 690 0.0283 0.0678 0.4169
Mom 690 0.0661 0.1459 0.4529

The combined table is mainly a bookkeeping and comparison device. It gathers all factor candidates used later in the chapter and makes the sample change visible. A model with more factors is easier to estimate mechanically, but the reader still needs to know which factor histories are being compared.

Code
ff5_perf |>
  ggplot(aes(annualized_volatility, annualized_return, color = return_per_unit_risk)) +
  geom_hline(yintercept = 0, color = "gray70", linewidth = 0.4) +
  geom_point(size = 3, show.legend = FALSE) +
  geom_text(aes(label = factor), vjust = -0.9, color = "black", size = 3.3) +
  labs(
    x = "Annualized volatility",
    y = "Annualized return",
    color = "Return per unit of risk"
  ) +
  scale_x_continuous(labels = scales::percent, expand = expansion(mult = c(0.08, 0.16))) +
  scale_y_continuous(labels = scales::percent, expand = expansion(mult = c(0.08, 0.22))) +
  scale_color_viridis_c(option = "C") +
  coord_cartesian(clip = "off") +
  theme_minimal(base_size = 12) +
  theme(
    legend.position = "none",
    plot.margin = margin(10, 34, 10, 10)
  )
Figure 4.6: Risk-return profile of the Fama-French five factors.

The five-factor risk-return graph shows whether the added profitability and investment factors occupy distinct positions relative to market, size, and value. If two factors have similar risk-return profiles, the regression may still distinguish them through covariance with asset returns.

Code
all_factor_perf |>
  ggplot(aes(annualized_volatility, annualized_return, color = return_per_unit_risk)) +
  geom_hline(yintercept = 0, color = "gray70", linewidth = 0.4) +
  geom_point(size = 3, show.legend = FALSE) +
  geom_text(aes(label = factor), vjust = -0.9, color = "black", size = 3.1) +
  labs(
    x = "Annualized volatility",
    y = "Annualized return",
    color = "Return per unit of risk"
  ) +
  scale_x_continuous(labels = scales::percent, expand = expansion(mult = c(0.08, 0.16))) +
  scale_y_continuous(labels = scales::percent, expand = expansion(mult = c(0.08, 0.22))) +
  scale_color_viridis_c(option = "C") +
  coord_cartesian(clip = "off") +
  theme_minimal(base_size = 12) +
  theme(
    legend.position = "none",
    plot.margin = margin(10, 34, 10, 10)
  )
Figure 4.7: Risk-return profile of all factors used in the chapter.

The all-factor graph is the broadest factor map in the chapter. It summarizes the candidate explanatory variables before the chapter turns to test assets, where these factor histories become regressors.

Code
ff5 |>
  select(date, Mkt_RF, SMB, HML, RMW, CMA) |>
  pivot_longer(-date, names_to = "factor", values_to = "return") |>
  group_by(factor) |>
  mutate(cumulative_return = cumprod(1 + return)) |>
  ungroup() |>
  order_series_by_final_value("factor") |>
  ggplot(aes(date, cumulative_return, color = factor)) +
  geom_line(linewidth = 0.8) +
  scale_y_log10(labels = scales::number_format(accuracy = 0.1)) +
  labs(x = "Date", y = "Cumulative return, log scale", color = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "right")
Figure 4.8: Cumulative returns of the Fama-French five factors.

The cumulative five-factor plot lets the reader compare the newer factors in their available sample. The shorter window should be kept in mind when comparing these paths with the earlier three-factor histories.

Code
all_factors |>
  select(date, Mkt_RF, SMB3, HML, Mom, SMB5, RMW, CMA) |>
  pivot_longer(-date, names_to = "factor", values_to = "return") |>
  group_by(factor) |>
  mutate(cumulative_return = cumprod(1 + return)) |>
  ungroup() |>
  order_series_by_final_value("factor") |>
  ggplot(aes(date, cumulative_return, color = factor)) +
  geom_line(linewidth = 0.8) +
  scale_y_log10(labels = scales::number_format(accuracy = 0.1)) +
  labs(x = "Date", y = "Cumulative return, log scale", color = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "right")
Figure 4.9: Cumulative returns of all factors used in the chapter.

The cumulative all-factor figure is useful for seeing which factors dominate visually and which have more modest paths. This is a descriptive step; the regression step later asks whether these paths help explain asset returns.

Code
ff5 |>
  select(date, Mkt_RF, SMB, HML, RMW, CMA) |>
  pivot_longer(-date, names_to = "factor", values_to = "return") |>
  ggplot(aes(return, fill = factor)) +
  geom_density(alpha = 0.45, adjust = 2.5) +
  geom_vline(xintercept = 0, linetype = 2, color = "gray30") +
  coord_cartesian(xlim = c(-0.2, 0.2)) +
  labs(x = "Monthly return", y = "Density", fill = NULL) +
  scale_x_continuous(labels = scales::percent) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Figure 4.10: Distribution of monthly returns for the Fama-French five factors.

The five-factor density plot shows how each factor behaves month by month. Students should read it together with the cumulative plot: the same factor can look attractive cumulatively while still having large monthly downside episodes.

Code
all_factors |>
  select(date, Mkt_RF, SMB3, HML, Mom, SMB5, RMW, CMA) |>
  pivot_longer(-date, names_to = "factor", values_to = "return") |>
  ggplot(aes(return, fill = factor)) +
  geom_density(alpha = 0.45, adjust = 2.5) +
  geom_vline(xintercept = 0, linetype = 2, color = "gray30") +
  coord_cartesian(xlim = c(-0.2, 0.2)) +
  labs(x = "Monthly return", y = "Density", fill = NULL) +
  scale_x_continuous(labels = scales::percent) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Figure 4.11: Distribution of monthly returns for all factors used in the chapter.

The final factor-density plot closes the descriptive block. At this point, the reader has seen factor returns as tables, cumulative paths, risk-return points, and monthly distributions. The next step is to use those factor returns to explain the behavior of test assets.

4.5 Test assets and the size-value portfolios

The chapter uses two kinds of test assets. The first is FCNTX, a single mutual fund. The second is the 25 Fama-French size-value portfolios. The single asset is useful for seeing one regression closely. The 25 portfolios are useful for testing whether the model explains a structured cross-section of returns.

Code
ticker <- "FCNTX"

fund_prices <- tq_get(
  ticker,
  from = "1980-01-01",
  to = sample_end,
  get = "stock.prices"
)

fund_returns <- fund_prices |>
  group_by(symbol) |>
  tq_transmute(
    select = adjusted,
    mutate_fun = periodReturn,
    period = "monthly",
    col_rename = "returns"
  ) |>
  ungroup()

fund_factors <- fund_returns |>
  filter(date <= max(all_factors$date)) |>
  mutate(date = lubridate::ceiling_date(date, "month") - lubridate::days(1)) |>
  left_join(all_factors, by = "date") |>
  mutate(Re = returns - RF) |>
  select(symbol, date, returns, Re, Mkt_RF, SMB3, HML, Mom, SMB5, RMW, CMA, RF)

portfolio_long <- portfolios_25 |>
  pivot_longer(-date, names_to = "symbol", values_to = "returns")

portfolio_labels <- function(symbols) {
  tibble(symbol = symbols) |>
    mutate(
      Size = case_when(
        grepl("^SMALL|^ME1", symbol) ~ "Small",
        grepl("^ME2", symbol) ~ "2",
        grepl("^ME3", symbol) ~ "3",
        grepl("^ME4", symbol) ~ "4",
        grepl("^BIG|^ME5", symbol) ~ "Big",
        TRUE ~ NA_character_
      ),
      Value = case_when(
        grepl("LoBM|BM1", symbol) ~ "Low",
        grepl("BM2", symbol) ~ "2",
        grepl("BM3", symbol) ~ "3",
        grepl("BM4", symbol) ~ "4",
        grepl("HiBM|BM5", symbol) ~ "High",
        TRUE ~ NA_character_
      ),
      Size = factor(Size, levels = c("Small", "2", "3", "4", "Big")),
      Value = factor(Value, levels = c("Low", "2", "3", "4", "High"))
    )
}

portfolio_label_tbl <- portfolio_labels(unique(portfolio_long$symbol))
label_portfolios <- c("SMALL LoBM", "SMALL HiBM", "BIG LoBM", "BIG HiBM")

portfolio_factor_data <- portfolio_long |>
  filter(date <= max(ff3_momentum$date)) |>
  inner_join(ff3_momentum, by = "date") |>
  mutate(Re = returns - RF) |>
  select(date, symbol, returns, Re, Mkt_RF, SMB3, HML, Mom) |>
  left_join(portfolio_label_tbl, by = "symbol")

portfolio_all_factor_data <- portfolio_long |>
  filter(date <= max(all_factors$date)) |>
  inner_join(all_factors, by = "date") |>
  mutate(Re = returns - RF) |>
  select(date, symbol, returns, Re, Mkt_RF, SMB3, HML, Mom, SMB5, RMW, CMA) |>
  left_join(portfolio_label_tbl, by = "symbol")

fund_factors |>
  select(symbol, date, returns, Re, Mkt_RF, SMB3, HML, Mom) |>
  slice(c(1:3, (n() - 2):n())) |>
  kable(
    caption = "FCNTX monthly returns joined to factor returns.",
    digits = 6,
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
FCNTX monthly returns joined to factor returns.
symbol date returns Re Mkt_RF SMB3 HML Mom
FCNTX 1980-01-31 -0.009674 -0.017674 0.0550 0.0162 0.0185 0.0745
FCNTX 1980-02-29 -0.016874 -0.025774 -0.0123 -0.0186 0.0059 0.0789
FCNTX 1980-03-31 -0.089431 -0.101531 -0.1290 -0.0670 -0.0096 -0.0958
FCNTX 2020-10-31 -0.031775 -0.031875 -0.0208 0.0434 0.0425 -0.0314
FCNTX 2020-11-30 0.083591 0.083491 0.1245 0.0581 0.0209 -0.1255
FCNTX 2020-12-31 0.027920 0.027820 0.0463 0.0501 -0.0168 -0.0237

This table is the first bridge between an individual asset and the factor data. The column returns is the raw FCNTX return, while Re subtracts the risk-free rate. The regression will use Re as the dependent variable and factor returns as explanatory variables, so this joined table is the dataset for the first asset-pricing exercise.

The single-asset exercise checks the data before estimating the regression. The price table verifies the adjusted series used to compute returns. The return table verifies the monthly transformation. The joined table verifies that the excess return and factors are aligned at month end. Reading the three tables in order shows exactly how a price series becomes the dependent variable in the CAPM regression.

Code
first_last_rows(fund_prices) |>
  select(symbol, date, adjusted) |>
  kable(
    caption = "First and last FCNTX adjusted-price observations.",
    digits = 6,
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
First and last FCNTX adjusted-price observations.
symbol date adjusted
FCNTX 1980-01-02 0.106178
FCNTX 1980-01-03 0.104777
FCNTX 1980-01-04 0.106365
FCNTX 2020-12-28 11.760649
FCNTX 2020-12-29 11.760649
FCNTX 2020-12-30 11.739572
Code
first_last_rows(fund_returns) |>
  kable(
    caption = "First and last FCNTX monthly raw returns.",
    digits = 6,
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
First and last FCNTX monthly raw returns.
symbol date returns
FCNTX 1980-01-31 -0.009674
FCNTX 1980-02-29 -0.016874
FCNTX 1980-03-31 -0.089431
FCNTX 2020-10-30 -0.031775
FCNTX 2020-11-30 0.083591
FCNTX 2020-12-30 0.027920
Code
first_last_rows(fund_factors) |>
  select(symbol, date, returns, Re, Mkt_RF, SMB3, HML, Mom, SMB5, RMW, CMA, RF) |>
  kable(
    caption = "First and last FCNTX excess returns joined to all factors.",
    digits = 6,
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
First and last FCNTX excess returns joined to all factors.
symbol date returns Re Mkt_RF SMB3 HML Mom SMB5 RMW CMA RF
FCNTX 1980-01-31 -0.009674 -0.017674 0.0550 0.0162 0.0185 0.0745 0.0188 -0.0184 0.0189 0.0080
FCNTX 1980-02-29 -0.016874 -0.025774 -0.0123 -0.0186 0.0059 0.0789 -0.0162 -0.0095 0.0292 0.0089
FCNTX 1980-03-31 -0.089431 -0.101531 -0.1290 -0.0670 -0.0096 -0.0958 -0.0697 0.0182 -0.0105 0.0121
FCNTX 2020-10-31 -0.031775 -0.031875 -0.0208 0.0434 0.0425 -0.0314 0.0467 -0.0077 -0.0057 0.0001
FCNTX 2020-11-30 0.083591 0.083491 0.1245 0.0581 0.0209 -0.1255 0.0712 -0.0225 0.0128 0.0001
FCNTX 2020-12-31 0.027920 0.027820 0.0463 0.0501 -0.0168 -0.0237 0.0493 -0.0197 -0.0006 0.0001
Code
first_last_rows(portfolio_factor_data) |>
  select(date, symbol, returns, Re, Mkt_RF, SMB3, HML, Mom) |>
  kable(
    caption = "First and last 25-portfolio excess-return rows joined to FF3 and momentum.",
    digits = 6,
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
First and last 25-portfolio excess-return rows joined to FF3 and momentum.
date symbol returns Re Mkt_RF SMB3 HML Mom
1927-01-31 SMALL LoBM 0.010954 0.008454 -0.0005 -0.0032 0.0458 0.0057
1927-01-31 ME1 BM2 -0.081352 -0.083852 -0.0005 -0.0032 0.0458 0.0057
1927-01-31 ME1 BM3 -0.048280 -0.050780 -0.0005 -0.0032 0.0458 0.0057
2020-12-31 ME5 BM3 0.034491 0.034391 0.0463 0.0501 -0.0168 -0.0237
2020-12-31 ME5 BM4 0.032226 0.032126 0.0463 0.0501 -0.0168 -0.0237
2020-12-31 BIG HiBM 0.080558 0.080458 0.0463 0.0501 -0.0168 -0.0237
Code
first_last_rows(portfolio_all_factor_data) |>
  select(date, symbol, returns, Re, Mkt_RF, SMB3, HML, Mom, SMB5, RMW, CMA) |>
  kable(
    caption = "First and last 25-portfolio excess-return rows joined to the expanded factor set.",
    digits = 6,
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
First and last 25-portfolio excess-return rows joined to the expanded factor set.
date symbol returns Re Mkt_RF SMB3 HML Mom SMB5 RMW CMA
1963-07-31 SMALL LoBM 0.011287 0.008587 -0.0039 -0.0057 -0.0081 0.0101 -0.0048 0.0064 -0.0115
1963-07-31 ME1 BM2 -0.003632 -0.006332 -0.0039 -0.0057 -0.0081 0.0101 -0.0048 0.0064 -0.0115
1963-07-31 ME1 BM3 0.007223 0.004523 -0.0039 -0.0057 -0.0081 0.0101 -0.0048 0.0064 -0.0115
2020-12-31 ME5 BM3 0.034491 0.034391 0.0463 0.0501 -0.0168 -0.0237 0.0493 -0.0197 -0.0006
2020-12-31 ME5 BM4 0.032226 0.032126 0.0463 0.0501 -0.0168 -0.0237 0.0493 -0.0197 -0.0006
2020-12-31 BIG HiBM 0.080558 0.080458 0.0463 0.0501 -0.0168 -0.0237 0.0493 -0.0197 -0.0006

The sequence of data checks has a simple logic. First, prices become monthly returns. Second, raw returns become excess returns. Third, each excess return is matched to factor returns for the same month. The 25-portfolio checks repeat the same process for many test assets at once.

Code
fund_factor_returns <- fund_factors |>
  select(date, Mkt_RF, SMB3, HML, Mom, SMB5, RMW, CMA, Re) |>
  rename(FCNTX = Re)

fund_factor_perf <- annualized_performance(
  fund_factor_returns,
  c("Mkt_RF", "SMB3", "HML", "Mom", "SMB5", "RMW", "CMA", "FCNTX")
)

fund_factor_perf |>
  mutate(across(c(annualized_return, annualized_volatility, return_per_unit_risk), ~ round(.x, 4))) |>
  kable(
    caption = "Annualized performance of FCNTX excess returns and the factors.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Annualized performance of FCNTX excess returns and the factors.
factor months annualized_return annualized_volatility return_per_unit_risk
SMB3 492 0.0060 0.1026 0.0584
SMB5 492 0.0069 0.1002 0.0690
HML 492 0.0145 0.1031 0.1407
Mom 492 0.0535 0.1558 0.3433
CMA 492 0.0266 0.0676 0.3929
RMW 492 0.0376 0.0818 0.4599
FCNTX 492 0.0771 0.1607 0.4798
Mkt_RF 492 0.0762 0.1561 0.4879

The FCNTX performance table places the mutual fund and factor portfolios on the same scale. This helps answer a descriptive question before estimation: does the fund look more volatile, less volatile, or more rewarded per unit of risk than the factors used to explain it?

Code
fund_factor_perf |>
  ggplot(aes(annualized_volatility, annualized_return, color = return_per_unit_risk)) +
  geom_hline(yintercept = 0, color = "gray70", linewidth = 0.4) +
  geom_point(size = 3, show.legend = FALSE) +
  geom_text(aes(label = factor), vjust = -0.9, color = "black", size = 3.1) +
  labs(
    x = "Annualized volatility",
    y = "Annualized return",
    color = "Return per unit of risk"
  ) +
  scale_x_continuous(labels = scales::percent, expand = expansion(mult = c(0.08, 0.16))) +
  scale_y_continuous(labels = scales::percent, expand = expansion(mult = c(0.08, 0.22))) +
  scale_color_viridis_c(option = "C") +
  coord_cartesian(clip = "off") +
  theme_minimal(base_size = 12) +
  theme(
    legend.position = "none",
    plot.margin = margin(10, 34, 10, 10)
  )
Figure 4.12: Risk-return profile of FCNTX excess returns and the factors.

The risk-return figure makes the same comparison visually. FCNTX appears as one point among the factor portfolios, which helps the reader see whether the fund is close to market-like behavior or whether it sits in a different region of the return-risk space.

Code
fund_factor_returns |>
  pivot_longer(-date, names_to = "series", values_to = "return") |>
  group_by(series) |>
  mutate(cumulative_return = cumprod(1 + return)) |>
  ungroup() |>
  order_series_by_final_value("series") |>
  ggplot(aes(date, cumulative_return, color = series)) +
  geom_line(linewidth = 0.8) +
  scale_y_log10(labels = scales::number_format(accuracy = 0.1)) +
  labs(x = "Date", y = "Cumulative return, log scale", color = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "right")
Figure 4.13: Cumulative excess returns of FCNTX and the factor portfolios.

The cumulative plot turns the FCNTX comparison into a time-series story. A fund can have a similar average return to a factor while following a different path through time. That difference is exactly what the regression tries to summarize with an intercept, beta, and residual.

Code
fund_factor_returns |>
  select(date, Mkt_RF, FCNTX) |>
  pivot_longer(-date, names_to = "series", values_to = "return") |>
  group_by(series) |>
  mutate(cumulative_return = cumprod(1 + return)) |>
  ungroup() |>
  order_series_by_final_value("series") |>
  ggplot(aes(date, cumulative_return, color = series)) +
  geom_line(linewidth = 0.8) +
  scale_y_log10(labels = scales::number_format(accuracy = 0.1)) +
  labs(x = "Date", y = "Cumulative return, log scale", color = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "right")
Figure 4.14: Cumulative excess returns of FCNTX and the market factor.

The market comparison isolates the CAPM idea. If FCNTX is mostly a market-risk asset, the FCNTX and market excess-return paths should move together in broad strokes. Differences between the paths foreshadow the residual component in the CAPM regression.

Code
fund_factor_returns |>
  select(date, Mkt_RF, FCNTX) |>
  pivot_longer(-date, names_to = "series", values_to = "return") |>
  ggplot(aes(return, fill = series)) +
  geom_density(alpha = 0.45, adjust = 2.5) +
  geom_vline(xintercept = 0, linetype = 2, color = "gray30") +
  coord_cartesian(xlim = c(-0.2, 0.2)) +
  labs(x = "Monthly excess return", y = "Density", fill = NULL) +
  scale_x_continuous(labels = scales::percent) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Figure 4.15: Distribution of monthly excess returns for FCNTX and the market factor.

The distribution plot adds another view of the same comparison. Overlap in the two densities suggests similar monthly-return behavior, while differences in spread or tails suggest that market exposure alone may leave unexplained variation.

The single-asset FCNTX example is useful because one regression can be read closely from beginning to end. The 25 Fama-French portfolios serve a different purpose: they create a structured cross-section. Each portfolio is formed by sorting firms by size and book-to-market, so the resulting grid gives the factor model a more demanding asset-pricing problem. The empirical task is now cross-sectional: explain systematic differences across size and value cells.

The same 25 portfolios can be read as investment portfolios through the usual risk-return lens. Annualized return, annualized volatility, and return per unit of risk show the economic content of the size-value grid. Each cell is a portfolio with its own average return and volatility.

Code
portfolio_perf <- portfolio_long |>
  group_by(symbol) |>
  summarise(
    months = n(),
    annualized_return = prod(1 + returns)^(12 / months) - 1,
    annualized_volatility = sd(returns) * sqrt(12),
    return_per_unit_risk = annualized_return / annualized_volatility,
    .groups = "drop"
  ) |>
  arrange(return_per_unit_risk) |>
  left_join(portfolio_label_tbl, by = "symbol")

portfolio_perf |>
  select(symbol, annualized_return, annualized_volatility, return_per_unit_risk) |>
  slice(c(1:5, (n() - 4):n())) |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(
    caption = "Lowest and highest return-risk ratios among the 25 size-value portfolios.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Lowest and highest return-risk ratios among the 25 size-value portfolios.
symbol annualized_return annualized_volatility return_per_unit_risk
SMALL LoBM 0.0294 0.4171 0.0705
ME1 BM2 0.0668 0.3380 0.1977
ME2 BM1 0.0795 0.2766 0.2876
BIG HiBM 0.1069 0.2957 0.3615
ME1 BM3 0.1143 0.3092 0.3697
ME5 BM3 0.1047 0.1944 0.5385
BIG LoBM 0.1006 0.1846 0.5447
ME3 BM2 0.1248 0.2253 0.5538
ME3 BM4 0.1345 0.2410 0.5582
ME3 BM3 0.1261 0.2247 0.5611

The table shows the extremes of the 25-portfolio risk-return ranking. This is a compact way to see that the size-value grid contains meaningful heterogeneity: some portfolios deliver much more return per unit of volatility than others.

Code
portfolio_perf |>
  ggplot(aes(annualized_volatility, annualized_return, color = return_per_unit_risk)) +
  geom_point(aes(size = return_per_unit_risk), alpha = 0.85, show.legend = FALSE) +
  geom_text(aes(label = symbol), vjust = -0.9, size = 2.4, color = "black") +
  labs(
    x = "Annualized volatility",
    y = "Annualized return",
    color = "Return per unit of risk"
  ) +
  scale_x_continuous(labels = scales::percent) +
  scale_y_continuous(labels = scales::percent) +
  scale_color_viridis_c(option = "C") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Figure 4.16: Risk-return profile of the 25 size-value portfolios.

The risk-return scatter makes the heterogeneity visible across all 25 portfolios. Dense labels are intentional here because each point is a named test asset. The graph prepares the reader for the asset-pricing question: can factor exposures explain why these portfolios occupy different locations?

Kenneth French’s data library often illustrates the extreme size-value portfolios because they make the empirical pattern easy to see. The plot below keeps the four corner portfolios: small growth, small value, big growth, and big value.

Code
portfolios_25 |>
  select(date, `SMALL HiBM`, `BIG HiBM`, `SMALL LoBM`, `BIG LoBM`) |>
  filter(date >= as.Date("1927-07-31"), date <= as.Date("1999-12-31")) |>
  pivot_longer(-date, names_to = "portfolio", values_to = "return") |>
  group_by(portfolio) |>
  mutate(cumulative_return = cumprod(1 + return)) |>
  ungroup() |>
  order_series_by_final_value("portfolio") |>
  ggplot(aes(date, cumulative_return, color = portfolio)) +
  geom_line(linewidth = 0.8) +
  scale_y_log10(labels = scales::number_format(accuracy = 0.1)) +
  labs(x = "Date", y = "Cumulative return, log scale", color = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "right")
Figure 4.17: Cumulative raw returns for the four corner size-value portfolios.

The four-corner cumulative plot focuses on the most interpretable cells of the grid. Comparing small versus big and low versus high book-to-market portfolios shows why the size-value sort became a standard test bed for factor models.

The rendered 3D surface gives a second view of the same size-value grid. The height and color of each cell represent the average monthly raw return of that portfolio.

Code
portfolio_means <- portfolio_long |>
  left_join(portfolio_label_tbl, by = "symbol") |>
  group_by(Size, Value) |>
  summarise(mean_return = mean(returns, na.rm = TRUE), .groups = "drop")

portfolio_surface_data <- portfolio_means |>
  mutate(
    ME = as.integer(Size),
    BM = as.integer(Value),
    raw_return_percent = 100 * mean_return
  ) |>
  select(ME, BM, raw_return_percent)

portfolio_surface_data |>
  mutate(
    ME = factor(
      ME,
      levels = 1:5,
      labels = c("ME1 small", "ME2", "ME3", "ME4", "ME5 big")
    ),
    BM = factor(
      BM,
      levels = 1:5,
      labels = c("BM1 low", "BM2", "BM3", "BM4", "BM5 high")
    )
  ) |>
  pivot_wider(names_from = BM, values_from = raw_return_percent) |>
  arrange(ME) |>
  mutate(across(-ME, ~ round(.x, 4))) |>
  kable(
    caption = "Mean monthly raw returns behind the size-value surface.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Mean monthly raw returns behind the size-value surface.
ME BM1 low BM2 BM3 BM4 BM5 high
ME1 small 0.8830 0.9688 1.2728 1.4296 1.5976
ME2 0.9524 1.2231 1.2415 1.3062 1.5142
ME3 1.0306 1.1965 1.1989 1.2863 1.3704
ME4 1.0321 1.0570 1.1196 1.2225 1.3006
ME5 big 0.9443 0.9099 0.9878 0.9115 1.1976

The surface table gives the numeric values behind the rendered image. It is the same information in tabular form: rows move from small to big firms, columns move from low to high book-to-market firms, and each entry is the average monthly raw return in percent for that cell.

Code
knitr::include_graphics("figures/ff-p25-surface.png")
Figure 4.18: Rendered surface of the 25 size-value portfolio mean returns.

The rendered surface is a visual summary of the size-value pattern. Peaks mark cells with higher average monthly returns; lower blocks mark cells with lower average returns. The point of the figure is to make the cross-sectional pattern visible before the regression tries to explain it with factor exposures.

Animated rendered surface of the 25 size-value portfolio mean returns.

The animation rotates the same surface. It uses the same data and helps the reader see the three-dimensional structure of the 25 portfolio averages.

4.6 Time-series asset pricing regressions

The time-series regression estimates factor exposures for each asset. It uses many months of returns for one asset at a time and asks how that asset moves with the factors. In matrix form, for asset \(i\) and \(K\) factors,

\[ R_{i,t}^e = \alpha_i + \boldsymbol{\beta}_i^\top \mathbf{f}_t + \varepsilon_{i,t}. \]

This equation has two roles. The slope vector \(\boldsymbol{\beta}_i\) measures exposure to systematic risk. A high market beta means that the asset tends to rise more in strong market months and fall more in weak market months. A high value beta means that the asset tends to move with the HML factor. The intercept \(\alpha_i\) measures the average excess return that remains after the factor exposures have been used.

The residual \(\varepsilon_{i,t}\) is a month-by-month error. It measures the difference between the observed excess return in month \(t\) and the fitted value from the regression. The alpha is different because it is constant over the sample. It measures an average level left after the factors are included. This distinction is useful: residuals diagnose short-run fit, while alphas diagnose average pricing.

In regression-based asset pricing, alpha is read as a pricing error when the factors are traded return portfolios. A model that prices the asset well should leave a small alpha, because the asset’s average excess return should be accounted for by its factor exposures. For one asset, the alpha tells us what the model leaves unexplained for that asset. For the 25 portfolios, the collection of alphas is more informative: a pattern of large positive and negative alphas would show that the model misses a systematic part of the cross-section.

The regression is therefore doing two jobs at once. Statistically, it is a least-squares fit of returns on factors. Economically, it is asking whether the factor model has removed the average return that should be explained by risk exposure. Cochrane’s regression tests use this second reading: the intercept is evidence about whether the model prices the asset.

The code below wraps that equation in a reusable function. The formula defines the right-hand side. The grouping variable symbol defines which asset receives its own regression.

The code follows the equation in four steps. First, group_by(symbol) separates the data by asset. Second, lm(formula, data = .x) estimates one time-series regression for that asset. Third, broom::tidy() extracts the estimated intercept and betas. Fourth, broom::augment() returns fitted values and residuals month by month. These four objects correspond directly to the mathematical notation: coefficients, fitted values, residuals, and model diagnostics.

Code
fit_factor_model <- function(data, formula) {
  data |>
    group_by(symbol) |>
    nest() |>
    mutate(
      model = map(data, ~ lm(formula, data = .x)),
      tidy = map(model, broom::tidy),
      glance = map(model, broom::glance),
      augmented = map2(model, data, ~ broom::augment(.x, data = .y))
    ) |>
    ungroup()
}

tidy_factor_model <- function(model_tbl) {
  model_tbl |>
    select(symbol, tidy) |>
    unnest(tidy)
}

glance_factor_model <- function(model_tbl) {
  model_tbl |>
    select(symbol, glance) |>
    unnest(glance)
}

augment_factor_model <- function(model_tbl) {
  model_tbl |>
    select(symbol, augmented) |>
    unnest(augmented)
}

For FCNTX, the CAPM regression is

\[ R_{\text{FCNTX},t}^e = \alpha_{\text{FCNTX}} + \beta_{\text{FCNTX},M} MKT_t + \varepsilon_{\text{FCNTX},t}. \]

Code
fund_capm <- fit_factor_model(fund_factors, Re ~ Mkt_RF)

tidy_factor_model(fund_capm) |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "CAPM time-series regression for FCNTX.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
CAPM time-series regression for FCNTX.
symbol term estimate std.error statistic p.value
FCNTX (Intercept) 0.000766 0.000977 0.783862 0.4335
FCNTX Mkt_RF 0.913507 0.021423 42.641817 0.0000

The CAPM table has two central entries. The market coefficient is the estimated market beta of FCNTX: it measures how much the fund’s excess return changes with the market excess return. The intercept is the fund’s average excess return after controlling for market exposure. A positive intercept means that FCNTX earned more, on average, than the CAPM would assign from its market beta over this sample. A negative intercept means that the market exposure assigns more average excess return than the fund actually earned.

The fitted value is the model-implied excess return for each month:

\[ \widehat{R}_{i,t}^e = \widehat{\alpha}_i + \widehat{\beta}_{i,M}MKT_t. \]

Code
augment_factor_model(fund_capm) |>
  ggplot(aes(date)) +
  geom_col(aes(y = Re, fill = "Observed excess return"), alpha = 0.5) +
  geom_col(aes(y = .fitted, fill = "CAPM fitted value"), alpha = 0.5) +
  labs(x = "Date", y = "Monthly excess return", fill = NULL) +
  scale_y_continuous(labels = scales::percent) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Figure 4.19: FCNTX excess returns and CAPM fitted values.

The fitted-value plot shows the CAPM as a month-by-month approximation. Months where the fitted bars and observed bars are close are months where market exposure explains much of the fund’s movement. Large gaps are residuals: the part of the FCNTX excess return left after the market factor is used.

The next graphs check whether the fitted values and residuals look reasonable. They are diagnostics for the time-series fit. They ask whether the CAPM regression is fitting the single asset in a stable way before the chapter moves to the 25-portfolio cross-section.

Code
fund_capm_aug <- augment_factor_model(fund_capm)

fund_capm_aug |>
  ggplot(aes(Re, .fitted)) +
  geom_point(alpha = 0.75) +
  geom_abline(slope = 1, intercept = 0, linetype = 2, color = "#D55E00", linewidth = 0.8) +
  coord_fixed() +
  labs(x = "Observed excess return", y = "Fitted excess return") +
  scale_x_continuous(labels = scales::percent) +
  scale_y_continuous(labels = scales::percent) +
  theme_minimal(base_size = 12)
Figure 4.20: Observed and fitted FCNTX excess returns.

The observed-versus-fitted scatter compresses the time-series fit into one plane. Points near the 45-degree line indicate months where the CAPM fitted return is close to the observed FCNTX excess return. The spread around the line shows the size of month-level errors.

Code
fund_capm_aug |>
  ggplot(aes(.fitted, .resid)) +
  geom_hline(yintercept = 0, color = "gray60", linewidth = 0.5) +
  geom_point(alpha = 0.75) +
  geom_smooth(method = "lm", formula = y ~ x, se = FALSE, linewidth = 0.8) +
  labs(x = "Fitted excess return", y = "Residual") +
  scale_x_continuous(labels = scales::percent) +
  scale_y_continuous(labels = scales::percent) +
  theme_minimal(base_size = 12)
Figure 4.21: FCNTX residuals against fitted values.

The residuals-against-fitted graph checks whether errors change systematically with the fitted return. A visible slope or curved pattern would suggest that the single market factor is leaving structure in the residuals.

Code
fund_capm_aug |>
  ggplot(aes(date, .resid)) +
  geom_hline(yintercept = 0, color = "gray60", linewidth = 0.5) +
  geom_col(fill = "#2C7FB8", alpha = 0.75) +
  labs(x = "Date", y = "Residual") +
  scale_y_continuous(labels = scales::percent) +
  theme_minimal(base_size = 12)
Figure 4.22: FCNTX CAPM residuals through time.

The residual time plot shows whether unexplained returns cluster in particular periods. Clusters are informative because they suggest that the model misses episodes where FCNTX behaves differently from the market factor.

Code
fund_residual_acf <- stats::acf(fund_capm_aug$.resid, lag.max = 24, plot = FALSE)
fund_acf_band <- 1.96 / sqrt(nrow(fund_capm_aug))

tibble(
  lag = as.numeric(fund_residual_acf$lag),
  acf = as.numeric(fund_residual_acf$acf)
) |>
  filter(lag > 0) |>
  ggplot(aes(lag, acf)) +
  geom_hline(yintercept = 0, color = "gray60", linewidth = 0.5) +
  geom_hline(
    yintercept = c(-fund_acf_band, fund_acf_band),
    color = "#D55E00",
    linetype = 2,
    linewidth = 0.7
  ) +
  geom_col(fill = "#2C7FB8", alpha = 0.75) +
  labs(x = "Lag", y = "ACF") +
  theme_minimal(base_size = 12)
Figure 4.23: Autocorrelation function for FCNTX CAPM residuals.

The autocorrelation plot asks whether residuals are serially related. In a clean time-series regression, residuals should have little predictable structure across lags. Large bars would indicate persistence in the unexplained component.

Code
fund_capm_aug |>
  group_by(symbol) |>
  summarise(
    ljung_box_p_value = Box.test(.resid, lag = 10, type = "Ljung-Box", fitdf = 2)$p.value,
    box_pierce_p_value = Box.test(.resid, lag = 10, type = "Box-Pierce", fitdf = 2)$p.value,
    .groups = "drop"
  ) |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "Residual autocorrelation checks for the FCNTX CAPM regression.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Residual autocorrelation checks for the FCNTX CAPM regression.
symbol ljung_box_p_value box_pierce_p_value
FCNTX 0.446494 0.459472

The Ljung-Box and Box-Pierce p-values formalize the residual-autocorrelation check. They summarize whether residual autocorrelation is large enough to be a statistical concern at the chosen lag.

For the 25 portfolios, the CAPM estimates 25 market betas:

Code
portfolio_capm <- fit_factor_model(portfolio_factor_data, Re ~ Mkt_RF)

tidy_factor_model(portfolio_capm) |>
  filter(term == "Mkt_RF") |>
  arrange(estimate) |>
  slice(c(1:5, (n() - 4):n())) |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "Lowest and highest CAPM market betas among the 25 portfolios.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Lowest and highest CAPM market betas among the 25 portfolios.
symbol term estimate std.error statistic p.value
ME5 BM2 Mkt_RF 0.948845 0.008293 114.41417 0
BIG LoBM Mkt_RF 0.956923 0.008451 113.23358 0
ME5 BM3 Mkt_RF 0.973296 0.011799 82.48904 0
ME4 BM1 Mkt_RF 1.087596 0.012244 88.82592 0
ME4 BM2 Mkt_RF 1.092239 0.010432 104.69953 0
SMALL HiBM Mkt_RF 1.378264 0.030716 44.87166 0
ME2 BM5 Mkt_RF 1.389511 0.025664 54.14304 0
ME1 BM2 Mkt_RF 1.404849 0.034684 40.50444 0
ME4 BM5 Mkt_RF 1.407824 0.023693 59.42046 0
SMALL LoBM Mkt_RF 1.612572 0.046799 34.45776 0

The beta table shows the range of market exposures across the 25 portfolios. This range is important because cross-sectional asset pricing needs variation in exposures. If all portfolios had nearly identical market betas, market beta would have little power to explain differences in average returns.

Code
tidy_factor_model(portfolio_capm) |>
  arrange(symbol, term) |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "CAPM coefficients for all 25 size-value portfolios.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
CAPM coefficients for all 25 size-value portfolios.
symbol term estimate std.error statistic p.value
BIG HiBM (Intercept) 0.000386 0.001466 0.263512 0.792204
BIG HiBM Mkt_RF 1.311025 0.027131 48.322346 0.000000
BIG LoBM (Intercept) 0.000235 0.000457 0.514319 0.607130
BIG LoBM Mkt_RF 0.956923 0.008451 113.233577 0.000000
ME1 BM2 (Intercept) -0.002389 0.001874 -1.274688 0.202683
ME1 BM2 Mkt_RF 1.404849 0.034684 40.504445 0.000000
ME1 BM3 (Intercept) 0.000817 0.001540 0.530487 0.595879
ME1 BM3 Mkt_RF 1.370093 0.028504 48.066185 0.000000
ME1 BM4 (Intercept) 0.003006 0.001452 2.070068 0.038674
ME1 BM4 Mkt_RF 1.276523 0.026874 47.499692 0.000000
ME2 BM1 (Intercept) -0.001812 0.001266 -1.431260 0.152633
ME2 BM1 Mkt_RF 1.272654 0.023426 54.326724 0.000000
ME2 BM2 (Intercept) 0.001222 0.001082 1.129123 0.259087
ME2 BM2 Mkt_RF 1.233511 0.020020 61.613546 0.000000
ME2 BM3 (Intercept) 0.001573 0.001013 1.553653 0.120548
ME2 BM3 Mkt_RF 1.201998 0.018741 64.136138 0.000000
ME2 BM4 (Intercept) 0.002183 0.001097 1.989744 0.046861
ME2 BM4 Mkt_RF 1.222209 0.020303 60.199031 0.000000
ME2 BM5 (Intercept) 0.002979 0.001387 2.148093 0.031919
ME2 BM5 Mkt_RF 1.389511 0.025664 54.143036 0.000000
ME3 BM1 (Intercept) -0.000804 0.000957 -0.840684 0.400704
ME3 BM1 Mkt_RF 1.251067 0.017708 70.650107 0.000000
ME3 BM2 (Intercept) 0.001550 0.000723 2.143617 0.032277
ME3 BM2 Mkt_RF 1.132414 0.013380 84.637675 0.000000
ME3 BM3 (Intercept) 0.001725 0.000745 2.315546 0.020762
ME3 BM3 Mkt_RF 1.123102 0.013787 81.459133 0.000000
ME3 BM4 (Intercept) 0.002106 0.000907 2.322279 0.020396
ME3 BM4 Mkt_RF 1.174158 0.016781 69.970866 0.000000
ME3 BM5 (Intercept) 0.001745 0.001260 1.384606 0.166447
ME3 BM5 Mkt_RF 1.366313 0.023325 58.577659 0.000000
ME4 BM1 (Intercept) 0.000217 0.000662 0.327664 0.743227
ME4 BM1 Mkt_RF 1.087596 0.012244 88.825923 0.000000
ME4 BM2 (Intercept) 0.000440 0.000564 0.780063 0.435518
ME4 BM2 Mkt_RF 1.092239 0.010432 104.699533 0.000000
ME4 BM3 (Intercept) 0.000989 0.000682 1.449711 0.147417
ME4 BM3 Mkt_RF 1.107003 0.012623 87.695115 0.000000
ME4 BM4 (Intercept) 0.001596 0.000863 1.850184 0.064549
ME4 BM4 Mkt_RF 1.164925 0.015966 72.964317 0.000000
ME4 BM5 (Intercept) 0.000763 0.001280 0.595705 0.551492
ME4 BM5 Mkt_RF 1.407824 0.023693 59.420458 0.000000
ME5 BM2 (Intercept) -0.000152 0.000448 -0.339499 0.734297
ME5 BM2 Mkt_RF 0.948845 0.008293 114.414172 0.000000
ME5 BM3 (Intercept) 0.000567 0.000638 0.889874 0.373724
ME5 BM3 Mkt_RF 0.973296 0.011799 82.489044 0.000000
ME5 BM4 (Intercept) -0.001169 0.000868 -1.347608 0.178056
ME5 BM4 Mkt_RF 1.106035 0.016059 68.872918 0.000000
SMALL HiBM (Intercept) 0.003906 0.001660 2.353169 0.018785
SMALL HiBM Mkt_RF 1.378264 0.030716 44.871658 0.000000
SMALL LoBM (Intercept) -0.004870 0.002529 -1.925603 0.054406
SMALL LoBM Mkt_RF 1.612572 0.046799 34.457758 0.000000

The full coefficient table keeps the individual portfolio estimates visible. Each portfolio has its own intercept and market beta, so the CAPM is estimated as 25 separate time-series regressions, one for each test asset. The market beta column measures exposure. The intercept column should be read as the CAPM pricing error for each portfolio. If those intercepts are systematically high for one part of the size-value grid and low for another, the CAPM is missing a cross-sectional pattern across many assets.

The 25 separate CAPM regressions have different levels of fit. A compact way to inspect them is to compare \(R^2\), residual standard error, and residual autocorrelation diagnostics across portfolios.

Code
portfolio_capm_diagnostics <- glance_factor_model(portfolio_capm) |>
  select(symbol, r.squared, adj.r.squared, sigma, AIC, BIC) |>
  arrange(r.squared)

portfolio_capm_diagnostics |>
  slice(c(1:5, (n() - 4):n())) |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(
    caption = "Lowest and highest CAPM goodness-of-fit values among the 25 portfolios.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Lowest and highest CAPM goodness-of-fit values among the 25 portfolios.
symbol r.squared adj.r.squared sigma AIC BIC
SMALL LoBM 0.5133 0.5128 0.0843 -2375.900 -2360.815
ME1 BM2 0.5930 0.5926 0.0624 -3051.749 -3036.664
SMALL HiBM 0.6413 0.6410 0.0553 -3325.851 -3310.767
ME1 BM4 0.6671 0.6668 0.0484 -3627.258 -3612.173
ME1 BM3 0.6723 0.6720 0.0513 -3494.418 -3479.333
ME4 BM3 0.8723 0.8722 0.0227 -5331.951 -5316.866
ME4 BM1 0.8751 0.8750 0.0220 -5400.756 -5385.671
ME4 BM2 0.9068 0.9068 0.0188 -5762.069 -5746.985
BIG LoBM 0.9193 0.9192 0.0152 -6237.228 -6222.144
ME5 BM2 0.9208 0.9207 0.0149 -6279.753 -6264.668

The goodness-of-fit table shows which portfolios are relatively well described by the market factor and which are less well described. A higher \(R^2\) means that market movements explain a larger fraction of that portfolio’s excess return variation.

Code
augment_factor_model(portfolio_capm) |>
  group_by(symbol) |>
  summarise(
    box_pierce_p_value = Box.test(.resid, lag = 10, type = "Box-Pierce", fitdf = 2)$p.value,
    .groups = "drop"
  ) |>
  arrange(box_pierce_p_value) |>
  slice(c(1:5, (n() - 4):n())) |>
  mutate(box_pierce_p_value = round(box_pierce_p_value, 6)) |>
  kable(
    caption = "Lowest and highest Box-Pierce p-values for the 25 CAPM residual series.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Lowest and highest Box-Pierce p-values for the 25 CAPM residual series.
symbol box_pierce_p_value
ME3 BM4 0.000000
ME2 BM4 0.000000
SMALL LoBM 0.000000
ME4 BM5 0.000000
ME4 BM2 0.000000
ME3 BM5 0.003763
ME1 BM3 0.008790
ME4 BM1 0.015569
SMALL HiBM 0.043218
ME5 BM2 0.075859

The residual test table extends the diagnostic logic from FCNTX to the full portfolio set. Portfolios with very small p-values deserve attention because their residuals show stronger serial structure under the CAPM specification.

4.7 Asset pricing errors and factor exposures

The Fama-French three-factor model is the main worked example for the rest of the chapter. It is detailed enough to show the full econometric route: first estimate time-series betas, then estimate cross-sectional prices of risk, then compare the beta-method with SDF-GMM estimates. Momentum and the five-factor model appear later as faster extensions. They are useful for comparison, while the main explanation is developed with FF3.

The Fama-French three-factor model adds size and value exposures:

\[ R_{i,t}^e = \alpha_i + \beta_{i,M}MKT_t + \beta_{i,S}SMB_t + \beta_{i,H}HML_t + \varepsilon_{i,t}. \]

The fitted expected excess return is obtained by taking the estimated factor loadings and multiplying them by average factor returns. The intercept is reported separately because, in asset pricing, alpha is the pricing error:

\[ \widehat{E}_{\text{factors}}(R_i^e) = \widehat{\beta}_{i,M}\bar{MKT} + \widehat{\beta}_{i,S}\bar{SMB} + \widehat{\beta}_{i,H}\bar{HML}. \]

This equation is the bridge between the regression table and the asset-pricing plot. The regression estimates betas. The asset-pricing restriction asks whether those betas, without an asset-specific alpha, explain average excess returns. If \(\widehat{\alpha}_i\) is economically important, the factor model is leaving a pricing error for asset \(i\).

The distinction is important for interpretation. A time-series regression with an intercept can always decompose the sample average return into two parts:

\[ \bar{R}_i^e = \widehat{\alpha}_i + \widehat{\boldsymbol{\beta}}_i^\top \bar{\mathbf{f}}. \]

The term \(\widehat{\boldsymbol{\beta}}_i^\top\bar{\mathbf{f}}\) is the return explained by factor exposures. The term \(\widehat{\alpha}_i\) is the part that remains asset-specific after those exposures have been used. The asset-pricing question is therefore direct: are the alphas small enough for the factor model to be a credible explanation of average returns?

This decomposition comes from averaging the monthly regression over the sample. Start with the fitted regression,

\[ R_{i,t}^e = \widehat{\alpha}_i + \widehat{\boldsymbol{\beta}}_i^\top\mathbf{f}_t + \widehat{\varepsilon}_{i,t}. \]

Averaging across months gives

\[ \bar{R}_i^e = \widehat{\alpha}_i + \widehat{\boldsymbol{\beta}}_i^\top\bar{\mathbf{f}} + \bar{\widehat{\varepsilon}}_i. \]

In an ordinary least-squares regression with an intercept, the sample average of the residuals is zero. That is why the average return can be written as alpha plus the factor-implied component. This algebra also explains why a time-series fitted-average plot can look strong: each asset has its own intercept. The stricter asset-pricing question begins when the chapter asks whether the factor exposures alone can organize average returns across many assets through common prices of risk.

The code mirrors the same decomposition. The function expected_return_from_betas() first computes average factor returns, then multiplies them by the estimated betas, then keeps alpha separately. The column factor_implied_percent is \(100\widehat{\boldsymbol{\beta}}_i^\top\bar{\mathbf{f}}\). The column alpha_percent is \(100\widehat{\alpha}_i\). The observed average excess return is the sum of those two pieces, up to rounding.

Code
portfolio_ff3 <- fit_factor_model(portfolio_factor_data, Re ~ Mkt_RF + SMB3 + HML)
ff3_betas <- tidy_factor_model(portfolio_ff3)

expected_return_from_betas <- function(data, model_tbl, factors) {
  factor_means <- data |>
    summarise(across(all_of(factors), mean, na.rm = TRUE)) |>
    pivot_longer(everything(), names_to = "term", values_to = "factor_mean")

  average_returns <- data |>
    group_by(symbol) |>
    summarise(ERe = mean(Re, na.rm = TRUE), .groups = "drop")

  alpha_tbl <- tidy_factor_model(model_tbl) |>
    filter(term == "(Intercept)") |>
    transmute(symbol, alpha = estimate)

  factor_implied <- tidy_factor_model(model_tbl) |>
    filter(term != "(Intercept)") |>
    select(symbol, term, estimate) |>
    inner_join(factor_means, by = "term") |>
    group_by(symbol) |>
    summarise(factor_implied = sum(estimate * factor_mean), .groups = "drop")

  factor_implied |>
    inner_join(alpha_tbl, by = "symbol") |>
    inner_join(average_returns, by = "symbol") |>
    left_join(portfolio_label_tbl, by = "symbol") |>
    mutate(
      ERe_percent = 100 * ERe,
      factor_implied_percent = 100 * factor_implied,
      alpha_percent = 100 * alpha,
      pricing_error_percent = ERe_percent - factor_implied_percent
    )
}

time_series_source_fit <- function(data, model_tbl, factors) {
  data |>
    group_by(symbol) |>
    summarise(
      ERe = mean(Re, na.rm = TRUE) * 100,
      `(Intercept)` = 1,
      across(all_of(factors), ~ mean(.x, na.rm = TRUE) * 100),
      .groups = "drop"
    ) |>
    pivot_longer(
      c(`(Intercept)`, all_of(factors)),
      names_to = "term",
      values_to = "Ef"
    ) |>
    inner_join(
      tidy_factor_model(model_tbl) |>
        select(symbol, term, estimate),
      by = c("symbol", "term")
    ) |>
    rename(betas = estimate) |>
    select(symbol, term, betas, ERe, Ef) |>
    group_by(symbol) |>
    mutate(pred_temp = Ef * betas, pred = sum(pred_temp)) |>
    ungroup() |>
    select(-pred_temp) |>
    left_join(portfolio_label_tbl, by = "symbol")
}

ff3_expected <- expected_return_from_betas(
  portfolio_factor_data,
  portfolio_ff3,
  c("Mkt_RF", "SMB3", "HML")
)

ff3_time_series_fit <- time_series_source_fit(
  portfolio_factor_data,
  portfolio_ff3,
  c("Mkt_RF", "SMB3", "HML")
)

portfolio_ff4 <- fit_factor_model(portfolio_factor_data, Re ~ Mkt_RF + SMB3 + HML + Mom)

ff4_time_series_fit <- time_series_source_fit(
  portfolio_factor_data,
  portfolio_ff4,
  c("Mkt_RF", "SMB3", "HML", "Mom")
)

This code block prepares the data for the figures that follow. It estimates the three-factor model, stores each portfolio’s factor exposures, computes the factor-implied part of average return, and keeps alpha as the remaining asset-specific component.

The size and value patterns can be inspected directly by reading average excess returns against market beta by portfolio sort.

The market-beta plots deliberately ignore the fitted-return axis and return to the raw relation between market exposure and average excess returns. If market beta were enough to explain the 25 portfolios, higher beta portfolios would line up with higher average returns in a stable pattern. The size and value groupings show whether portfolios with similar market beta still differ because of their position in the size-value grid.

Code
ff3_betas |>
  filter(term == "Mkt_RF") |>
  rename(market_beta = estimate) |>
  select(symbol, market_beta) |>
  inner_join(ff3_expected, by = "symbol") |>
  ggplot(aes(market_beta, ERe_percent)) +
  geom_point(size = 2.2) +
  labs(x = "Market beta", y = "Observed average excess return (%)") +
  theme_minimal(base_size = 12)
Figure 4.24: Market beta and average excess returns for the 25 portfolios.

The plain market-beta graph is the starting point for the anomaly discussion. If market beta alone organized average returns, the cloud would show a clear upward relation. A scattered pattern motivates the size and value groupings in the next two figures.

Code
ff3_betas |>
  filter(term == "Mkt_RF") |>
  rename(market_beta = estimate) |>
  select(symbol, market_beta) |>
  inner_join(ff3_expected, by = "symbol") |>
  ggplot(aes(market_beta, ERe_percent, color = Size, group = Size)) +
  geom_line(alpha = 0.9, linewidth = 1.5) +
  geom_point(size = 2) +
  geom_text(aes(label = Size), color = "black", size = 3, show.legend = FALSE) +
  labs(x = "Market beta", y = "Observed average excess return (%)", color = "Size") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Figure 4.25: Market beta and average excess returns by size portfolio.

The size plot connects each point to its size group. Lines that separate by size indicate that portfolios with similar market beta can have different average returns because they belong to different size cells.

Code
ff3_betas |>
  filter(term == "Mkt_RF") |>
  rename(market_beta = estimate) |>
  select(symbol, market_beta) |>
  inner_join(ff3_expected, by = "symbol") |>
  ggplot(aes(market_beta, ERe_percent, color = Value, group = Value)) +
  geom_line(alpha = 0.9, linewidth = 1.5) +
  geom_point(size = 2) +
  geom_text(aes(label = Value), color = "black", size = 3, show.legend = FALSE) +
  labs(x = "Market beta", y = "Observed average excess return (%)", color = "Value") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Figure 4.26: Market beta and average excess returns by value portfolio.

The value plot reads the same beta-return relation through book-to-market groups. Separation by value group is the visual motivation for adding the HML factor to the model.

The next diagnostic compares observed average excess returns with predictions assembled from the time-series regression output. This is the last first-pass graph before the chapter moves to cross-sectional pricing. For each portfolio, the regression estimates an intercept and factor loadings. In consistent decimal units, the sample-average decomposition is

\[ \bar{R}_i^e = \widehat{\alpha}_i + \widehat{\beta}_{i,M}\bar{MKT} + \widehat{\beta}_{i,S}\bar{SMB} + \widehat{\beta}_{i,H}\bar{HML}. \]

The plotted Prediction uses the convention of the FF exercise being replicated here:

\[ \mathrm{Prediction}_i = \widehat{\alpha}_i + \widehat{\beta}_{i,M}(100\bar{MKT}) + \widehat{\beta}_{i,S}(100\bar{SMB}) + \widehat{\beta}_{i,H}(100\bar{HML}). \]

These prediction figures should be read as orientation and replication checks. They preserve the display convention of the source FF exercise: average excess returns and factor means are displayed in percent, while the intercept is kept as the coefficient returned by the regression. This convention preserves the numerical values of the FF graphic.

The formal asset-pricing interpretation remains the decimal-unit decomposition above. That equation is the coherent accounting identity: average excess return equals alpha plus the factor-implied component. The figures in this block have a narrower role. They show whether portfolios with high observed average returns also receive high first-pass fitted values before the chapter moves to the stricter cross-sectional pricing test.

Code
ff3_ts_limits <- range(
  c(ff3_time_series_fit$pred, ff3_time_series_fit$ERe),
  na.rm = TRUE
) * c(0.95, 1.05)

ff3_time_series_fit |>
  ggplot(aes(pred, ERe)) +
  geom_abline(
    slope = 1,
    intercept = 0,
    linetype = "longdash",
    color = "orange",
    linewidth = 1.4,
    alpha = 0.55
  ) +
  geom_text(aes(label = symbol), size = 2) +
  coord_fixed(xlim = ff3_ts_limits, ylim = ff3_ts_limits, clip = "off") +
  labs(
    x = "Prediction",
    y = "25 portfolios mean excess returns",
    title = "Time series regressions.",
    subtitle = "Re ~ Mkt_RF + SMB3 + HML",
    caption = paste(
      "Monthly data:",
      min(portfolio_factor_data$date),
      "to",
      max(portfolio_factor_data$date)
    )
  ) +
  theme_minimal(base_size = 12) +
  theme(plot.margin = margin(10, 28, 10, 10))
Figure 4.27: Time-series fitted average excess returns under the FF3 model.

Adding momentum repeats the same first-pass diagnostic with a broader factor set:

\[ \mathrm{Prediction}_i = \widehat{\alpha}_i + \widehat{\beta}_{i,M}(100\bar{MKT}) + \widehat{\beta}_{i,S}(100\bar{SMB}) + \widehat{\beta}_{i,H}(100\bar{HML}) + \widehat{\beta}_{i,MOM}(100\bar{MOM}). \]

The reading is the same as in the FF3 figure. Adding momentum changes the factor set used to compute the prediction, while the observed average excess return on the vertical axis remains the same portfolio statistic. The figure is a source exercise replication check and a transition to the next section. The cross-sectional test begins after this point, where common prices of risk are estimated across portfolios.

Code
ff4_ts_limits <- range(
  c(ff4_time_series_fit$pred, ff4_time_series_fit$ERe),
  na.rm = TRUE
) * c(0.95, 1.05)

ff4_time_series_fit |>
  ggplot(aes(pred, ERe)) +
  geom_abline(
    slope = 1,
    intercept = 0,
    linetype = "longdash",
    color = "orange",
    linewidth = 1.4,
    alpha = 0.55
  ) +
  geom_text(aes(label = symbol), size = 2) +
  coord_fixed(xlim = ff4_ts_limits, ylim = ff4_ts_limits, clip = "off") +
  labs(
    x = "Prediction",
    y = "E(Re)",
    title = "Time series regressions.",
    subtitle = "Re ~ Mkt_RF + SMB3 + HML + Mom",
    caption = paste(
      "Monthly data:",
      min(portfolio_factor_data$date),
      "to",
      max(portfolio_factor_data$date)
    )
  ) +
  theme_minimal(base_size = 12) +
  theme(plot.margin = margin(10, 28, 10, 10))
Figure 4.28: Time-series fitted average excess returns under the FF3-plus-momentum model.

The remaining asset-pricing tests use a deliberate division of labor. FF3 is the detailed benchmark. The two extensions are included to show how the results change when the factor set expands.

Code
model_comparison_roadmap <- tibble(
  model = c("FF3", "FF3 plus momentum", "FF5"),
  role = c("Detailed benchmark", "Fast comparison", "Fast comparison"),
  factors = c(
    "Mkt_RF, SMB3, HML",
    "Mkt_RF, SMB3, HML, Mom",
    "Mkt_RF, SMB5, HML, RMW, CMA"
  ),
  sample_start = c(
    min(portfolio_factor_data$date),
    min(portfolio_factor_data$date),
    min(portfolio_all_factor_data$date)
  ),
  sample_end = c(
    max(portfolio_factor_data$date),
    max(portfolio_factor_data$date),
    max(portfolio_all_factor_data$date)
  ),
  months = c(
    n_distinct(portfolio_factor_data$date),
    n_distinct(portfolio_factor_data$date),
    n_distinct(portfolio_all_factor_data$date)
  )
)

model_comparison_roadmap |>
  mutate(across(c(sample_start, sample_end), as.character)) |>
  kable(
    caption = "Roadmap for the FF3 benchmark and the comparison models.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Roadmap for the FF3 benchmark and the comparison models.
model role factors sample_start sample_end months
FF3 Detailed benchmark Mkt_RF, SMB3, HML 1927-01-31 2020-12-31 1128
FF3 plus momentum Fast comparison Mkt_RF, SMB3, HML, Mom 1927-01-31 2020-12-31 1128
FF5 Fast comparison Mkt_RF, SMB5, HML, RMW, CMA 1963-07-31 2020-12-31 690

The roadmap prevents repetition. FF3 receives the full mathematical and statistical explanation. The momentum and five-factor rows will be read later as robustness comparisons: the same test assets, a different factor set, and a different sample for FF5 because those factors start later.

4.8 Cross-sectional asset pricing tests

Time-series regressions estimate betas. Cross-sectional regressions ask whether those betas explain average returns across assets. The data shape changes at this point. The regression now has one row for each test portfolio. The dependent variable is the portfolio’s average excess return. The explanatory variables are the betas estimated in the previous time-series step.

The intuition is the same as pricing any characteristic. A beta is a quantity of exposure. A lambda is the price attached to that exposure. The fitted value is the total average excess return assigned by the pricing equation after adding up the priced exposures. For a three-factor model, the fitted value is

\[ \widehat{\bar{R}}_i^e = \widehat{\lambda}_0 + \widehat{\lambda}_M \widehat{\beta}_{i,M} + \widehat{\lambda}_S \widehat{\beta}_{i,S} + \widehat{\lambda}_H \widehat{\beta}_{i,H}. \]

This equation should be read portfolio by portfolio. A portfolio with high market beta receives more fitted return when \(\widehat{\lambda}_M\) is positive. A portfolio with high value beta receives more fitted return when \(\widehat{\lambda}_H\) is positive. If a beta is high but its lambda is close to zero, that exposure does little work in the fitted cross-section.

The beta-pricing equation is

\[ \bar{R}_i^e = \lambda_0 + \lambda_M \widehat{\beta}_{i,M} + \lambda_S \widehat{\beta}_{i,S} + \lambda_H \widehat{\beta}_{i,H} + \eta_i. \]

This is the expected-return beta representation emphasized in regression-based asset pricing. Cochrane’s chapter 12 frames this equation as a cross-sectional line fitted through test assets (Cochrane 2005). The betas are the right-hand-side variables, the lambdas are the coefficients of the pricing line, and the residuals are pricing errors. The OLS beta-method chooses the lambdas that minimize the sum of squared pricing errors across the 25 portfolios:

\[ \min_{\lambda_0,\boldsymbol{\lambda}} \sum_{i=1}^{N} \widehat{\eta}_i^2. \]

This objective explains the visual role of the fitted-return plot. The 45-degree line represents perfect pricing for every test asset. OLS chooses one common pricing equation that brings the points as close as possible to that line in a least-squares sense.

This is a static two-pass cross-sectional regression. It is related to the Fama-MacBeth logic used in asset pricing (Fama and MacBeth 1973), because both approaches first estimate exposures and then price those exposures in a cross-section. A formal Fama-MacBeth procedure would repeat the cross-sectional regression period by period and estimate inference from the time series of price-of-risk estimates. The chapter uses the simpler static version to keep the bridge between betas, prices of risk, fitted returns, and pricing errors transparent. In this chapter, the cross-section is the set of 25 size-value portfolios.

The equation has a clear economic reading. The left-hand side, \(\bar{R}_i^e\), is the average excess return of portfolio \(i\). The regressors are estimated exposures from the first-pass time-series regressions. The coefficients \(\lambda_M\), \(\lambda_S\), and \(\lambda_H\) are prices of risk in the cross-section. A positive \(\lambda_H\) means that, holding market and size exposure fixed, portfolios with higher value exposure have higher average excess returns in this sample. A low fitted value for a portfolio means that its estimated betas explain less than its observed average return under this second-pass equation.

The units are also important. The response variable is measured as average monthly excess return in percent. Therefore a \(\lambda\) coefficient is the change in average monthly excess return, in percentage points, associated with one additional unit of the corresponding beta, holding the other betas fixed. A positive price of risk rewards exposure to that factor in this cross-section. A negative price of risk penalizes that exposure in this sample. A coefficient near zero says that the estimated beta does little to organize average returns once the other betas are included.

The residual \(\eta_i\) is the cross-sectional pricing error. It is the vertical gap between the portfolio’s observed average excess return and the fitted value assigned by the common pricing equation. This gap is different from the time-series residual \(\varepsilon_{i,t}\): \(\varepsilon_{i,t}\) is a month-level error, while \(\eta_i\) is an average-return error for one portfolio.

Cochrane often writes the cross-sectional residual as an alpha. This chapter uses \(\eta_i\) in the cross-sectional equation to keep two objects separate: \(\alpha_i\) is the time-series intercept for asset \(i\), and \(\eta_i\) is the cross-sectional pricing error after the common lambda estimates are applied. Both objects diagnose model fit, but they come from different regressions.

There are therefore two layers of estimation:

Layer Object estimated Data orientation Economic role
Time-series regression \(\alpha_i\) and \(\boldsymbol{\beta}_i\) for each asset One asset across many months Measures exposure to factors
Cross-sectional regression \(\lambda\) coefficients Many assets in one average-return cross-section Prices those exposures

The first layer asks how each portfolio moves with the factors. The second layer asks whether those exposures explain why some portfolios earn higher average excess returns than others.

The important change is the source of the fitted value. In the time-series figures, each portfolio has its own intercept, so each regression can match its own sample average closely. In the cross-sectional regression, the fitted value comes from one common pricing equation shared by the 25 portfolios. A portfolio receives a high fitted average return only if its estimated exposures combine with the estimated prices of risk to justify that return. This is the economic content of the second pass: betas become explanatory variables for average returns.

Code
beta_wide <- ff3_betas |>
  select(symbol, term, estimate) |>
  pivot_wider(names_from = term, values_from = estimate) |>
  select(symbol, Mkt_RF, SMB3, HML) |>
  rename(
    beta_market = Mkt_RF,
    beta_size = SMB3,
    beta_value = HML
  )

cross_section_data <- ff3_expected |>
  select(symbol, ERe_percent) |>
  inner_join(beta_wide, by = "symbol")

cross_section_fit <- lm(ERe_percent ~ beta_market + beta_size + beta_value, data = cross_section_data)

cross_section_data |>
  arrange(symbol) |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "Database for the beta-method cross-sectional regression.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Database for the beta-method cross-sectional regression.
symbol ERe_percent beta_market beta_size beta_value
BIG HiBM 0.928851 1.176016 -0.153532 1.007109
BIG LoBM 0.673261 1.028478 -0.150567 -0.261489
ME1 BM2 0.715026 1.075452 1.531718 0.219261
ME1 BM3 1.012036 1.049472 1.234896 0.513966
ME1 BM4 1.167404 0.945327 1.235871 0.577586
ME2 BM1 0.682987 1.092125 1.141651 -0.234571
ME2 BM2 0.959735 1.021739 0.998126 0.125271
ME2 BM3 0.973527 0.986224 0.824533 0.353568
ME2 BM4 1.048203 0.968149 0.824440 0.588164
ME2 BM5 1.241404 1.069016 0.921565 0.881021
ME3 BM1 0.769063 1.132901 0.807744 -0.224530
ME3 BM2 0.923917 1.019604 0.556805 0.037253
ME3 BM3 0.935126 0.986860 0.441466 0.316164
ME3 BM4 1.007859 0.992305 0.473991 0.557333
ME3 BM5 1.102275 1.115432 0.581282 0.854144
ME4 BM1 0.760184 1.081084 0.329958 -0.347466
ME4 BM2 0.785630 1.030804 0.235967 0.099252
ME4 BM3 0.850570 1.012678 0.205751 0.336159
ME4 BM4 0.950634 1.032493 0.203184 0.572564
ME4 BM5 1.032213 1.194122 0.313638 0.940638
ME5 BM2 0.629075 0.982748 -0.192872 0.018778
ME5 BM3 0.717629 0.966926 -0.240743 0.321634
ME5 BM4 0.634083 1.035462 -0.183668 0.647847
SMALL HiBM 1.326447 0.986067 1.281172 0.898013
SMALL LoBM 0.608025 1.268284 1.463056 0.391072
Code
broom::tidy(cross_section_fit) |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "Cross-sectional regression using estimated betas.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Cross-sectional regression using estimated betas.
term estimate std.error statistic p.value
(Intercept) 1.667080 0.340597 4.894583 0.000077
beta_market -0.923050 0.327989 -2.814269 0.010392
beta_size 0.128757 0.046483 2.769981 0.011475
beta_value 0.337060 0.064128 5.256082 0.000033
Code
broom::glance(cross_section_fit) |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "Goodness-of-fit summary for the beta-method cross-sectional regression.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Goodness-of-fit summary for the beta-method cross-sectional regression.
r.squared adj.r.squared sigma statistic p.value df logLik AIC BIC deviance df.residual nobs
0.654312 0.604928 0.12413 13.24945 4.5e-05 3 18.86654 -27.73308 -21.6387 0.323575 21 25

The cross-sectional block should be read as a second pass. The first table is the data matrix: one row per portfolio, average excess return as the outcome, and estimated betas as regressors. The coefficient table estimates the prices attached to those betas. The goodness-of-fit table summarizes how much of the 25-portfolio average-return pattern is captured by this beta-pricing equation. Reading these tables in order prevents a common confusion. The beta table by itself only reports exposures. The coefficient table tells how the cross-section prices those exposures. The fitted-value plot then translates the estimated pricing equation back into portfolio-level average returns.

The coefficient table should be read in three passes. First, inspect the sign of each \(\lambda\): it tells whether the exposure is rewarded or penalized in this sample. Second, inspect the magnitude: it tells how much the fitted average return changes when the beta changes by one unit. Third, inspect the standard errors and p-values with caution: the betas were estimated in a first pass, so the usual OLS table is an orientation device for inference. This is why the chapter later separates estimation logic from formal inference limits.

In this sample, the beta-method gives a concrete result. The cross-sectional \(R^2\) is 0.65, so the estimated betas capture a substantial part of the 25-portfolio average-return pattern while leaving visible pricing errors. The estimated market price of risk is -0.92 monthly percentage points, the size price is 0.13, and the value price is 0.34. The signs say that, conditional on the other estimated betas, size and value exposure raise fitted average excess returns in this cross-section, while market beta lowers the fitted average return over this sample. The positive value coefficient is especially important for the narrative because the size-value portfolios were built precisely to make value-related return patterns visible.

Code
cross_section_aug <- broom::augment(cross_section_fit, data = cross_section_data)

cross_section_aug |>
  left_join(portfolio_label_tbl, by = "symbol") |>
  ggplot(aes(.fitted, ERe_percent)) +
  geom_abline(slope = 1, intercept = 0, linetype = 2, color = "#D55E00", linewidth = 0.8) +
  geom_text(aes(label = symbol), size = 2.2) +
  coord_fixed(xlim = c(0.5, 1.4), ylim = c(0.5, 1.4), clip = "off") +
  labs(
    x = "Cross-sectional fitted return (%)",
    y = "Observed average excess return (%)"
  ) +
  theme_minimal(base_size = 12)
Figure 4.29: Cross-sectional fitted returns using estimated betas.

The fitted-return figure is the visual version of the second-pass regression. Each label is one portfolio. Points close to the 45-degree line are portfolios whose average returns are close to what the estimated beta prices predict. Points above the line have observed average returns larger than the fitted value; points below the line have observed average returns smaller than the fitted value.

Code
cross_section_aug |>
  select(symbol, ERe_percent, fitted_return_percent = .fitted) |>
  arrange(symbol) |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(
    caption = "Observed and fitted average excess returns from the beta method.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Observed and fitted average excess returns from the beta method.
symbol ERe_percent fitted_return_percent
BIG HiBM 0.9289 0.9012
BIG LoBM 0.6733 0.6102
ME1 BM2 0.7150 0.9455
ME1 BM3 1.0120 1.0306
ME1 BM4 1.1674 1.1483
ME2 BM1 0.6830 0.7269
ME2 BM2 0.9597 0.8947
ME2 BM3 0.9735 0.9821
ME2 BM4 1.0482 1.0778
ME2 BM5 1.2414 1.0959
ME3 BM1 0.7691 0.6497
ME3 BM2 0.9239 0.8102
ME3 BM3 0.9351 0.9196
ME3 BM4 1.0079 1.0000
ME3 BM5 1.1023 1.0002
ME4 BM1 0.7602 0.5946
ME4 BM2 0.7856 0.7794
ME4 BM3 0.8506 0.8721
ME4 BM4 0.9506 0.9332
ME4 BM5 1.0322 0.9223
ME5 BM2 0.6291 0.7415
ME5 BM3 0.7176 0.8520
ME5 BM4 0.6341 0.9060
SMALL HiBM 1.3264 1.2245
SMALL LoBM 0.6080 0.8166

The fitted-value table gives the same comparison portfolio by portfolio. It is useful when labels overlap in the figure or when the reader wants to inspect a particular size-value cell directly.

The coefficients \(\lambda_M\), \(\lambda_S\), and \(\lambda_H\) are estimated prices of risk. A positive \(\lambda_H\), for example, means that portfolios with higher value exposure have higher average excess returns in this cross-section, conditional on the other beta estimates.

The fitted-return graph and fitted-value table should be read together. The graph shows the overall geometry: points close to the 45-degree line are well-explained by the beta-pricing equation. The table makes the individual portfolio comparison auditable. Large vertical gaps indicate portfolios whose average returns remain far from the fitted values implied by the estimated prices of market, size, and value exposure.

The reason for doing this second pass is conceptual. A time-series regression gives each portfolio’s market beta, size beta, and value beta. The next question is whether those betas carry enough information to account for the portfolio’s average return. The second pass asks that question directly. It treats the estimated betas as the characteristics to be priced and asks whether a common set of lambdas can assign reasonable average returns to all 25 portfolios at once.

The equation also clarifies the division of responsibility between data, estimation, and interpretation:

Piece Where it comes from What it tells the reader
\(\bar{R}_i^e\) Average of portfolio excess returns The return pattern the model must explain.
\(\widehat{\beta}_{i,M}, \widehat{\beta}_{i,S}, \widehat{\beta}_{i,H}\) First-pass time-series regressions The estimated exposures of each portfolio.
\(\widehat{\lambda}_M, \widehat{\lambda}_S, \widehat{\lambda}_H\) Second-pass cross-sectional regression The price attached to each exposure.
\(\widehat{\bar{R}}_i^e\) Fitted value from the second pass The average return assigned by the model.
\(\widehat{\eta}_i\) Observed minus fitted average return The cross-sectional pricing error.

This table is also a guide to the code. cross_section_data contains the first two rows of the table: average excess returns and estimated betas. cross_section_fit estimates the lambdas. cross_section_aug adds fitted values and pricing errors. The code therefore follows the same sequence as the econometric argument: construct the cross-section, estimate prices of risk, then inspect the errors.

4.9 SDF-GMM and pricing logic

The beta-method prices exposures. The SDF-GMM method prices payoffs through moments. Both approaches ask whether the factor model can explain average excess returns, but they organize the evidence differently. The beta-method starts with estimated betas. SDF-GMM starts with a pricing condition and turns that condition into sample moments.

The purpose of this section is to make Cochrane’s chapter 13 operational. The logic is:

Step Question Object
Pricing condition What should be true if the model prices excess returns? \(E(m_tR_{i,t}^e)=0\)
Linear SDF How does the discount factor depend on factors? \(m_t=1-\mathbf{b}^{\top}\mathbf{f}_t\)
Sample moments What does the model leave unexplained in the data? \(\mathbf{g}_T(\mathbf{b})=\bar{\mathbf{R}}^e-\mathbf{D}\mathbf{b}\)
First-stage GMM What SDF loadings minimize unweighted pricing errors? \(W=I\)
Second-stage GMM What changes when errors are weighted by their covariance? \(W=\widehat{\mathbf{S}}^{-1}\)
J-test Are the remaining pricing errors jointly large? \(J=T\mathbf{g}'W\mathbf{g}\)

This sequence answers a question beyond the regression table: after choosing the SDF loadings, are the leftover pricing errors small enough to be consistent with the model?

The stochastic discount factor view starts from the pricing condition

\[ E(m_t R_{i,t}^e)=0. \]

This notation is standard in modern asset pricing (Cochrane 2005). The object \(m_t\) is a stochastic discount factor. It assigns value today to future payoffs. For an excess return, the initial cost is zero after financing at the risk-free rate. The model therefore asks for the discounted excess return to average to zero. The equation says that, after the model applies its discount factor, each test asset should have zero remaining average payoff.

Read the equation literally from inside to outside. Start with the excess return of asset \(i\) in month \(t\), \(R_{i,t}^e\). Multiply that payoff by the discount factor in the same month, \(m_t\). Then take the average implied by \(E(\cdot)\). If the model prices the asset, that average is zero because an excess return has zero initial cost. In the data, the sample version of this average is the pricing error. A model with small pricing errors is a model whose discount factor makes the test assets close to zero-cost payoffs in present-value terms.

The equation can be read in three layers. First, \(R_{i,t}^e\) is the payoff to be priced. Second, \(m_t\) is the risk adjustment applied to that payoff. Third, the expectation averages the risk-adjusted payoff over time or across states. If the model is correct, positive and negative risk-adjusted payoffs balance out for every test asset. The empirical version checks whether that balance is close enough in the sample.

The condition is easier to read through states of the world. If \(m_t\) is high in bad states, then payoffs received in those states are especially valuable. An asset that performs poorly when \(m_t\) is high must compensate investors with a higher average excess return. This is the same economic idea behind factor betas, written in pricing-condition form.

The condition also explains why covariance is central. The expectation \(E(m_tR_{i,t}^e)\) is an average product. It is affected by the average excess return of the asset and by the way that return co-moves with the discount factor. A high average excess return can be consistent with the model when the asset pays off in states where the discount factor is low. The asset is then less valuable as insurance, so investors require compensation through a higher average return.

A linear SDF can be normalized as

\[ m_t = 1 - \mathbf{b}^{\top}\mathbf{f}_t. \]

This is the normalization used for the GMM calculation below. The constant is fixed at one, and the vector \(\mathbf{b}\) controls how strongly each factor enters the discount factor. If a factor captures bad times for investors, assets that pay poorly when that factor is adverse should require higher average excess returns. The SDF formulation and the beta-pricing formulation organize the same economic idea: systematic covariance with factors drives average returns.

The minus sign is useful for interpretation. With \(m_t=1-\mathbf{b}^{\top}\mathbf{f}_t\), a positive loading \(b_j\) means that months with a high value of factor \(f_j\) reduce the discount factor. In the expected-return equation below, that same positive \(b_j\) gives a positive contribution to a portfolio whose return has a positive sample moment with \(f_j\). The loading is therefore read as the price attached to that return-factor moment.

Substituting the linear SDF into the pricing condition gives the bridge from the theory to the data:

\[ E\left[ \left(1-\mathbf{b}^{\top}\mathbf{f}_t\right)R_{i,t}^e \right] = 0. \]

Expanding the expectation gives

\[ E(R_{i,t}^e) - \mathbf{b}^{\top}E(\mathbf{f}_tR_{i,t}^e) = 0. \]

Solving for the average excess return gives

\[ E(R_{i,t}^e) = \mathbf{b}^{\top}E(\mathbf{f}_tR_{i,t}^e). \]

This last equation is the key pedagogical bridge. The average excess return is linked to moments of the form \(E(f_{j,t}R_{i,t}^e)\). The code below constructs sample versions of those moments for every portfolio and every factor. The estimated cross-sectional regression then asks whether those moments explain the same average excess returns studied in the beta method.

For the full cross-section of \(N\) test assets, define

\[ \bar{\mathbf{R}}^e = \frac{1}{T} \sum_{t=1}^{T} \mathbf{R}_t^e, \qquad \mathbf{D} = \frac{1}{T} \sum_{t=1}^{T} \mathbf{R}_t^e\mathbf{f}_t^{\top}. \]

The sample pricing errors are

\[ \mathbf{g}_T(\mathbf{b}) = \bar{\mathbf{R}}^e - \mathbf{D}\mathbf{b}. \]

This notation is compact, so it is worth mapping it to the objects used in the code:

Mathematical object Code object Interpretation
\(\bar{\mathbf{R}}^e\) mean_returns The vector of average excess returns for the 25 portfolios.
\(\mathbf{D}\) moment_matrix The matrix of return-factor sample moments.
\(\mathbf{b}\) b1 or b2 The SDF loadings estimated by GMM.
\(\mathbf{D}\mathbf{b}\) fitted_returns The average excess returns implied by the SDF.
\(\mathbf{g}_T(\mathbf{b})\) pricing_errors Observed average returns minus fitted average returns.
\(\widehat{\mathbf{S}}_1\) moment_covariance The covariance matrix of the pricing-moment time series.

The dimensions also help. In the FF3 case there are \(N=25\) test portfolios and \(K=3\) factors. Therefore \(\bar{\mathbf{R}}^e\) has 25 entries, \(\mathbf{D}\) has 25 rows and 3 columns, \(\mathbf{b}\) has 3 entries, and \(\mathbf{g}_T(\mathbf{b})\) has 25 pricing errors. The model has fewer SDF loadings than portfolios, so it is overidentified. That is why the chapter can estimate the loadings and still test whether the remaining errors are too large.

The goal of SDF-GMM is to choose \(\mathbf{b}\) so these pricing errors are small. The first-stage and second-stage estimates differ in how they weight those errors.

The first-stage GMM estimate uses identity weighting, \(W=I\). Cochrane shows that, in this linear SDF setting, this estimate is an OLS cross-sectional regression of average returns on second moments (Cochrane 2005):

\[ \widehat{\mathbf{b}}_1 = (\mathbf{D}^{\top}\mathbf{D})^{-1} \mathbf{D}^{\top} \bar{\mathbf{R}}^e. \]

This equation is OLS without a cross-sectional intercept. The left-hand side is the vector of average excess returns. The regressors are the columns of \(\mathbf{D}\), the return-factor second moments. The estimated vector \(\widehat{\mathbf{b}}_1\) gives the SDF loadings that make \(\mathbf{D}\widehat{\mathbf{b}}_1\) as close as possible to \(\bar{\mathbf{R}}^e\) in ordinary least-squares distance.

The identity matrix is the natural first step because it requires only the average returns and the return-factor moments. It gives each portfolio’s pricing error equal weight in the criterion \(\mathbf{g}_T(\mathbf{b})^{\top}\mathbf{g}_T(\mathbf{b})\). This stage is the cleanest place to see the geometry of the SDF problem: choose the factor loadings that make the fitted vector \(\mathbf{D}\mathbf{b}\) close to the observed vector \(\bar{\mathbf{R}}^e\).

The empirical content comes from sample moments such as

\[ \widehat{E}(R_i^e f_j) = \frac{1}{T} \sum_{t=1}^{T} R_{i,t}^e f_{j,t}. \]

The moment \(\widehat{E}(R_i^e f_j)\) is an average product. It is large when portfolio \(i\) tends to have high excess returns in months when factor \(j\) is high. It is negative when the portfolio tends to move against the factor. In applied work, these moments are useful because they turn co-movement into regressors that can be used in a cross-sectional pricing equation.

In matrix form, the code builds these quantities with

\[ \mathbf{D} = \frac{1}{T} \mathbf{R}^{\top}\mathbf{F}, \]

where \(\mathbf{R}\) is a \(T \times N\) matrix of portfolio excess returns and \(\mathbf{F}\) is a \(T \times K\) matrix of factor returns. The result \(\mathbf{D}\) is an \(N \times K\) matrix: one row for each test portfolio and one column for each factor moment.

The code below implements the FF3 first-stage GMM estimate. All estimation is done in decimal returns. Tables and figures convert average returns and pricing errors to percent for readability.

Code
symmetric_inverse <- function(matrix, tolerance = 1e-10) {
  matrix <- (matrix + t(matrix)) / 2
  eigen_decomp <- eigen(matrix, symmetric = TRUE)
  cutoff <- tolerance * max(abs(eigen_decomp$values))
  inverse_values <- ifelse(abs(eigen_decomp$values) > cutoff, 1 / eigen_decomp$values, 0)
  eigen_decomp$vectors %*% diag(inverse_values, nrow = length(inverse_values)) %*% t(eigen_decomp$vectors)
}

prepare_sdf_gmm_inputs <- function(data, factors) {
  returns_wide <- data |>
    select(date, symbol, Re) |>
    pivot_wider(names_from = symbol, values_from = Re) |>
    arrange(date)

  factors_wide <- data |>
    distinct(date, across(all_of(factors))) |>
    arrange(date)

  model_data <- inner_join(returns_wide, factors_wide, by = "date") |>
    filter(if_all(-date, ~ !is.na(.x)))

  asset_names <- setdiff(names(returns_wide), "date")
  returns_matrix <- model_data |>
    select(all_of(asset_names)) |>
    as.matrix()

  factor_matrix <- model_data |>
    select(all_of(factors)) |>
    as.matrix()

  moment_matrix <- crossprod(returns_matrix, factor_matrix) / nrow(returns_matrix)
  rownames(moment_matrix) <- asset_names
  colnames(moment_matrix) <- factors

  list(
    dates = model_data$date,
    assets = asset_names,
    factors = factors,
    returns_matrix = returns_matrix,
    factor_matrix = factor_matrix,
    mean_returns = colMeans(returns_matrix),
    moment_matrix = moment_matrix,
    months = nrow(returns_matrix),
    n_assets = ncol(returns_matrix),
    n_factors = length(factors)
  )
}

moment_series_for_b <- function(inputs, b) {
  sdf_values <- as.numeric(1 - inputs$factor_matrix %*% b)
  inputs$returns_matrix * sdf_values
}

fit_sdf_gmm_first_stage <- function(data, factors, model_label) {
  inputs <- prepare_sdf_gmm_inputs(data, factors)
  D <- inputs$moment_matrix
  mean_returns <- inputs$mean_returns
  dtd_inverse <- symmetric_inverse(crossprod(D))
  b1 <- as.numeric(dtd_inverse %*% crossprod(D, mean_returns))
  names(b1) <- factors

  fitted_returns <- as.numeric(D %*% b1)
  pricing_errors <- mean_returns - fitted_returns
  moment_series <- moment_series_for_b(inputs, b1)
  centered_moments <- scale(moment_series, center = TRUE, scale = FALSE)
  moment_covariance <- crossprod(centered_moments) / inputs$months
  vcov_b1 <- dtd_inverse %*% t(D) %*% moment_covariance %*% D %*% dtd_inverse / inputs$months

  coefficient_table <- tibble(
    model = model_label,
    stage = "First-stage GMM (W = I)",
    factor = factors,
    estimate = b1,
    std_error = sqrt(diag(vcov_b1)),
    t_statistic = estimate / std_error
  )

  fit_table <- tibble(
    model = model_label,
    stage = "First-stage GMM (W = I)",
    symbol = inputs$assets,
    observed_percent = 100 * mean_returns,
    fitted_percent = 100 * fitted_returns,
    pricing_error_percent = 100 * pricing_errors
  ) |>
    left_join(portfolio_label_tbl, by = "symbol")

  summary_table <- tibble(
    model = model_label,
    stage = "First-stage GMM (W = I)",
    months = inputs$months,
    n_assets = inputs$n_assets,
    n_factors = inputs$n_factors,
    identity_criterion = inputs$months * sum(pricing_errors^2),
    rmse_percent = sqrt(mean((100 * pricing_errors)^2)),
    mae_percent = mean(abs(100 * pricing_errors)),
    max_abs_error_percent = max(abs(100 * pricing_errors))
  )

  moment_table <- as_tibble(D, rownames = "symbol") |>
    rename_with(~ paste0("moment_", .x), all_of(factors)) |>
    inner_join(
      fit_table |> select(symbol, observed_percent, fitted_percent, pricing_error_percent),
      by = "symbol"
    ) |>
    left_join(portfolio_label_tbl, by = "symbol")

  list(
    inputs = inputs,
    b = b1,
    moment_covariance = moment_covariance,
    coefficient_table = coefficient_table,
    fit_table = fit_table,
    summary_table = summary_table,
    moment_table = moment_table
  )
}

sdf_ff3_first_stage <- fit_sdf_gmm_first_stage(
  portfolio_factor_data,
  c("Mkt_RF", "SMB3", "HML"),
  "FF3"
)

sdf_data <- sdf_ff3_first_stage$moment_table

sdf_aug <- sdf_ff3_first_stage$fit_table |>
  transmute(
    symbol,
    ERe_percent = observed_percent,
    .fitted = fitted_percent,
    .resid = pricing_error_percent
  )

sdf_data |>
  arrange(symbol) |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "FF3 first-stage GMM data: average returns, second moments, and pricing errors.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
FF3 first-stage GMM data: average returns, second moments, and pricing errors.
symbol moment_Mkt_RF moment_SMB3 moment_HML observed_percent fitted_percent pricing_error_percent Size Value
BIG HiBM 0.003831 0.000663 0.001808 0.928851 1.095269 -0.166418 Big High
BIG LoBM 0.002796 0.000390 0.000158 0.673261 0.588068 0.085193 Big Low
ME1 BM2 0.004086 0.002175 0.001029 0.715026 0.959260 -0.244234 Small 2
ME1 BM3 0.004006 0.001913 0.001347 1.012036 1.009415 0.002621 Small 3
ME1 BM4 0.003748 0.001870 0.001383 1.167404 0.964493 0.202911 Small 4
ME2 BM1 0.003704 0.001725 0.000415 0.682987 0.781368 -0.098381 2 Low
ME2 BM2 0.003610 0.001602 0.000815 0.959735 0.839736 0.119999 2 2
ME2 BM3 0.003520 0.001442 0.001056 0.973527 0.870524 0.103003 2 3
ME2 BM4 0.003584 0.001469 0.001341 1.048203 0.935117 0.113087 2 4
ME2 BM5 0.004078 0.001669 0.001772 1.241404 1.109469 0.131935 2 High
ME3 BM1 0.003648 0.001416 0.000399 0.769063 0.776142 -0.007079 3 Low
ME3 BM2 0.003317 0.001144 0.000638 0.923917 0.760754 0.163164 3 2
ME3 BM3 0.003291 0.001052 0.000952 0.935126 0.815931 0.119195 3 3
ME3 BM4 0.003443 0.001125 0.001261 1.007859 0.901634 0.106226 3 4
ME3 BM5 0.004001 0.001347 0.001705 1.102275 1.091078 0.011197 3 High
ME4 BM1 0.003177 0.000890 0.000151 0.760184 0.649863 0.110321 4 Low
ME4 BM2 0.003192 0.000835 0.000668 0.785630 0.749826 0.035804 4 2
ME4 BM3 0.003239 0.000832 0.000951 0.850570 0.811610 0.038961 4 3
ME4 BM4 0.003412 0.000877 0.001256 0.950634 0.901877 0.048757 4 4
ME4 BM5 0.004116 0.001134 0.001807 1.032213 1.139493 -0.107280 4 High
ME5 BM2 0.002770 0.000364 0.000476 0.629075 0.642147 -0.013072 Big 2
ME5 BM3 0.002846 0.000354 0.000840 0.717629 0.725041 -0.007412 Big 3
ME5 BM4 0.003222 0.000496 0.001283 0.634083 0.879055 -0.244972 Big 4
SMALL HiBM 0.004051 0.001988 0.001811 1.326447 1.101799 0.224648 Small High
SMALL LoBM 0.004676 0.002236 0.001318 0.608025 1.131167 -0.523142 Small Low
Code
sdf_ff3_first_stage$coefficient_table |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "FF3 first-stage GMM SDF loadings with identity weighting.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
FF3 first-stage GMM SDF loadings with identity weighting.
model stage factor estimate std_error t_statistic
FF3 First-stage GMM (W = I) Mkt_RF 2.040156 0.633179 3.222085
FF3 First-stage GMM (W = I) SMB3 -0.293621 0.946250 -0.310300
FF3 First-stage GMM (W = I) HML 1.842783 0.839478 2.195152
Code
sdf_ff3_first_stage$summary_table |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "FF3 first-stage GMM fit summary.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
FF3 first-stage GMM fit summary.
model stage months n_assets n_factors identity_criterion rmse_percent mae_percent max_abs_error_percent
FF3 First-stage GMM (W = I) 1128 25 3 0.075147 0.163241 0.12116 0.523142

The first table is the cross-sectional dataset: one row per portfolio, return-factor second moments as regressors, observed average excess return as the target, and the resulting pricing error. The coefficient table reports the SDF loadings \(\widehat{\mathbf{b}}_1\). The summary table reports the identity-weighted GMM criterion and descriptive pricing-error measures. This is the exact first-stage object in Cochrane’s linear SDF-GMM setup: OLS cross-sectional fitting with \(W=I\).

The three tables should be read in order:

Table How to read it Why it is useful
First-stage data Compare observed_percent, fitted_percent, and pricing_error_percent portfolio by portfolio. It shows the actual cross-section being priced.
SDF loadings Read the sign and size of each \(\widehat b_j\) relative to its standard error. It shows which factor moments receive positive or negative prices.
Fit summary Compare RMSE, MAE, maximum absolute error, and the identity criterion. It summarizes the remaining pricing errors in a few auditable numbers.

The coefficient table is about the discount factor. The fit table is about the test assets. Keeping those two readings separate helps avoid a common confusion: a factor can have an economically meaningful SDF loading and the model can still leave sizable pricing errors for specific portfolios.

The second-stage estimate changes the weighting matrix. Define the moment vector for month \(t\) as

\[ \mathbf{h}_t(\mathbf{b}) = \mathbf{R}_t^e \left(1-\mathbf{b}^{\top}\mathbf{f}_t\right). \]

The first-stage estimate gives a first estimate of the covariance matrix of these moments:

\[ \widehat{\mathbf{S}}_1 = \frac{1}{T} \sum_{t=1}^{T} \left( \mathbf{h}_t(\widehat{\mathbf{b}}_1) - \mathbf{g}_T(\widehat{\mathbf{b}}_1) \right) \left( \mathbf{h}_t(\widehat{\mathbf{b}}_1) - \mathbf{g}_T(\widehat{\mathbf{b}}_1) \right)^{\top}. \]

This covariance matrix measures how the pricing moments move together across months. If two portfolios are formed from nearby size-value cells, their pricing errors may be highly related. If one portfolio has especially volatile moment errors, its raw pricing error is less informative than a stable error of the same size. The second stage uses this information when it decides which errors should have more influence in the estimation criterion.

The second-stage estimate uses \(W=\widehat{\mathbf{S}}_1^{-1}\):

\[ \widehat{\mathbf{b}}_2 = \left( \mathbf{D}^{\top} \widehat{\mathbf{S}}_1^{-1} \mathbf{D} \right)^{-1} \mathbf{D}^{\top} \widehat{\mathbf{S}}_1^{-1} \bar{\mathbf{R}}^e. \]

This is the GLS version of the same cross-sectional regression. Pricing errors with larger estimated covariance receive different weight from pricing errors with smaller estimated covariance. The point is efficiency: the second stage uses the covariance structure of the pricing moments, while the first stage weights all pricing errors equally.

The change from first stage to second stage has a specific interpretation. The target is still the same vector of average excess returns, and the regressors are still the same second moments. The weighting matrix changes the metric used to judge the errors. As a result, the second-stage fitted returns can move closer to some portfolios and farther from others. That movement is expected: the second-stage estimate tries to reduce the covariance-weighted error, so a smaller weighted criterion may occur even when the unweighted RMSE changes only slightly.

Code
fit_sdf_gmm_second_stage <- function(first_stage_result) {
  inputs <- first_stage_result$inputs
  D <- inputs$moment_matrix
  mean_returns <- inputs$mean_returns
  W <- symmetric_inverse(first_stage_result$moment_covariance)
  dwd_inverse <- symmetric_inverse(t(D) %*% W %*% D)

  b2 <- as.numeric(dwd_inverse %*% t(D) %*% W %*% mean_returns)
  names(b2) <- inputs$factors

  fitted_returns <- as.numeric(D %*% b2)
  pricing_errors <- mean_returns - fitted_returns
  vcov_b2 <- dwd_inverse / inputs$months

  coefficient_table <- tibble(
    model = first_stage_result$coefficient_table$model[1],
    stage = "Second-stage GMM (W = S^-1)",
    factor = inputs$factors,
    estimate = b2,
    std_error = sqrt(diag(vcov_b2)),
    t_statistic = estimate / std_error
  )

  fit_table <- tibble(
    model = first_stage_result$coefficient_table$model[1],
    stage = "Second-stage GMM (W = S^-1)",
    symbol = inputs$assets,
    observed_percent = 100 * mean_returns,
    fitted_percent = 100 * fitted_returns,
    pricing_error_percent = 100 * pricing_errors
  ) |>
    left_join(portfolio_label_tbl, by = "symbol")

  summary_table <- tibble(
    model = first_stage_result$coefficient_table$model[1],
    stage = "Second-stage GMM (W = S^-1)",
    months = inputs$months,
    n_assets = inputs$n_assets,
    n_factors = inputs$n_factors,
    weighted_criterion = inputs$months * as.numeric(t(pricing_errors) %*% W %*% pricing_errors),
    rmse_percent = sqrt(mean((100 * pricing_errors)^2)),
    mae_percent = mean(abs(100 * pricing_errors)),
    max_abs_error_percent = max(abs(100 * pricing_errors))
  )

  list(
    W = W,
    b = b2,
    coefficient_table = coefficient_table,
    fit_table = fit_table,
    summary_table = summary_table
  )
}

sdf_ff3_second_stage <- fit_sdf_gmm_second_stage(sdf_ff3_first_stage)

sdf_ff3_second_stage$coefficient_table |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "FF3 second-stage GMM SDF loadings with covariance weighting.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
FF3 second-stage GMM SDF loadings with covariance weighting.
model stage factor estimate std_error t_statistic
FF3 Second-stage GMM (W = S^-1) Mkt_RF 1.907719 0.608701 3.134084
FF3 Second-stage GMM (W = S^-1) SMB3 0.981290 0.878460 1.117057
FF3 Second-stage GMM (W = S^-1) HML 1.384296 0.818106 1.692073
Code
sdf_ff3_second_stage$summary_table |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "FF3 second-stage GMM fit summary.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
FF3 second-stage GMM fit summary.
model stage months n_assets n_factors weighted_criterion rmse_percent mae_percent max_abs_error_percent
FF3 Second-stage GMM (W = S^-1) 1128 25 3 78.38851 0.185631 0.112067 0.685839

The first-stage and second-stage coefficient tables should be compared by factor. Large changes in \(\widehat{\mathbf{b}}\) indicate that the covariance weighting changes which pricing errors receive more influence in the estimation. The summary tables should be read together as well: the first-stage criterion reports the identity-weighted fit, and the second-stage criterion reports the covariance-weighted fit.

There are three useful reading questions at this point. First, do the signs of the SDF loadings remain stable across stages? Second, does covariance weighting materially change the size of the loadings? Third, do the pricing-error summaries improve in the metric that each stage is designed to minimize? These questions connect the algebra to interpretation: the second stage is a change in statistical weighting, so its economic meaning should be assessed through the fitted returns and the remaining pricing errors.

For FF3, the market loading remains positive across stages: 2.04 in the first stage and 1.91 in the second stage. The HML loading also remains positive, moving from 1.84 to 1.38. The size loading changes more, from -0.29 to 0.98. That movement tells the reader where covariance weighting has the largest effect: the statistical metric changes the role assigned to the size moment more than the role assigned to the market moment.

The overidentifying-restrictions test uses the pricing errors that remain after estimation:

\[ J = T \mathbf{g}_T(\widehat{\mathbf{b}})^{\top} \widehat{\mathbf{S}}_1^{+} \mathbf{g}_T(\widehat{\mathbf{b}}), \]

where \(\widehat{\mathbf{S}}_1^{+}\) is the generalized inverse of the first-stage moment covariance matrix. Under the usual large-sample GMM conditions, the statistic is compared with

\[ \chi^2_{N-K}, \]

where \(N\) is the number of test assets and \(K\) is the number of SDF factors. For FF3, \(N=25\) and \(K=3\), so the test has 22 degrees of freedom.

The J-test asks a joint question. After estimating \(K\) SDF loadings, are the \(N\) pricing conditions close enough to zero as a group? The degrees of freedom are \(N-K\) because the model uses \(K\) parameters to fit \(N\) moment conditions. A large p-value means the test provides little statistical evidence against the joint restrictions. A small p-value means the remaining pricing errors are too large relative to their covariance under the chi-square approximation. The test is statistical; the tables and figures that follow show the economic location and size of those errors.

Code
gmm_pricing_error_vector <- function(first_stage_result, b) {
  inputs <- first_stage_result$inputs
  as.numeric(inputs$mean_returns - inputs$moment_matrix %*% b)
}

gmm_j_test_table <- function(first_stage_result, second_stage_result) {
  inputs <- first_stage_result$inputs
  W <- second_stage_result$W
  df <- inputs$n_assets - inputs$n_factors

  bind_rows(
    tibble(
      model = first_stage_result$coefficient_table$model[1],
      stage = "First-stage GMM (W = I)",
      pricing_error = list(gmm_pricing_error_vector(first_stage_result, first_stage_result$b))
    ),
    tibble(
      model = second_stage_result$coefficient_table$model[1],
      stage = "Second-stage GMM (W = S^-1)",
      pricing_error = list(gmm_pricing_error_vector(first_stage_result, second_stage_result$b))
    )
  ) |>
    mutate(
      months = inputs$months,
      n_assets = inputs$n_assets,
      n_factors = inputs$n_factors,
      df = df,
      identity_criterion = map_dbl(pricing_error, ~ inputs$months * sum(.x^2)),
      j_statistic = map_dbl(pricing_error, ~ inputs$months * as.numeric(t(.x) %*% W %*% .x)),
      p_value = 1 - pchisq(j_statistic, df = df)
    ) |>
    select(-pricing_error)
}

sdf_ff3_j_tests <- gmm_j_test_table(sdf_ff3_first_stage, sdf_ff3_second_stage)

sdf_ff3_j_tests |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "FF3 SDF-GMM overidentifying-restrictions tests.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
FF3 SDF-GMM overidentifying-restrictions tests.
model stage months n_assets n_factors df identity_criterion j_statistic p_value
FF3 First-stage GMM (W = I) 1128 25 3 22 0.075147 80.60545 0
FF3 Second-stage GMM (W = S^-1) 1128 25 3 22 0.097174 78.38851 0

The first-stage row reports two useful quantities. The identity criterion is the objective minimized by the first-stage OLS cross-sectional estimate. The J-statistic uses the estimated moment covariance matrix for the chi-square test. The second-stage row uses the same covariance weighting that defines the second-stage estimate. A small p-value indicates that the remaining pricing errors are large relative to their estimated covariance.

The distinction between the identity criterion and the J-statistic is important. The identity criterion is the fitting objective for the first-stage estimate. The J-statistic is the formal overidentifying-restrictions test, computed with the covariance weighting. Reporting both makes the calculation transparent: one number shows the first-stage distance that was minimized, and the other number gives the chi-square test of the pricing restrictions.

Code
sdf_aug |>
  left_join(portfolio_label_tbl, by = "symbol") |>
  ggplot(aes(.fitted, ERe_percent)) +
  geom_abline(slope = 1, intercept = 0, linetype = 2, color = "#D55E00", linewidth = 0.8) +
  geom_text(aes(label = symbol), size = 2.2) +
  coord_fixed(xlim = c(0.5, 1.4), ylim = c(0.5, 1.4), clip = "off") +
  labs(
    x = "First-stage SDF-GMM fitted return (%)",
    y = "Observed average excess return (%)"
  ) +
  theme_minimal(base_size = 12)
Figure 4.30: First-stage SDF-GMM fitted returns.

The first-stage SDF-GMM fitted-return figure should be compared with the beta-method fitted figure. Both plots use the same vertical axis, observed average excess return. The horizontal axis changes: the beta method uses fitted returns from estimated betas, while first-stage SDF-GMM uses fitted returns from return-factor second moments.

The reading rule is simple. A point on the dashed line means the model-implied average return equals the observed average return for that portfolio. A point above the line means the observed average return is higher than the fitted return, so the pricing error is positive. A point below the line means the model assigns too much average return to that portfolio. Labels far from the line are the portfolios that should be inspected in the fitted-value table.

Code
sdf_aug |>
  select(symbol, ERe_percent, fitted_return_percent = .fitted) |>
  arrange(symbol) |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(
    caption = "Observed and fitted average excess returns from first-stage SDF-GMM.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Observed and fitted average excess returns from first-stage SDF-GMM.
symbol ERe_percent fitted_return_percent
BIG HiBM 0.9289 1.0953
BIG LoBM 0.6733 0.5881
ME1 BM2 0.7150 0.9593
ME1 BM3 1.0120 1.0094
ME1 BM4 1.1674 0.9645
ME2 BM1 0.6830 0.7814
ME2 BM2 0.9597 0.8397
ME2 BM3 0.9735 0.8705
ME2 BM4 1.0482 0.9351
ME2 BM5 1.2414 1.1095
ME3 BM1 0.7691 0.7761
ME3 BM2 0.9239 0.7608
ME3 BM3 0.9351 0.8159
ME3 BM4 1.0079 0.9016
ME3 BM5 1.1023 1.0911
ME4 BM1 0.7602 0.6499
ME4 BM2 0.7856 0.7498
ME4 BM3 0.8506 0.8116
ME4 BM4 0.9506 0.9019
ME4 BM5 1.0322 1.1395
ME5 BM2 0.6291 0.6421
ME5 BM3 0.7176 0.7250
ME5 BM4 0.6341 0.8791
SMALL HiBM 1.3264 1.1018
SMALL LoBM 0.6080 1.1312

The fitted-value table makes the first-stage SDF-GMM comparison explicit for each portfolio. Together with the graph, it lets the reader see which portfolios are matched closely and which remain difficult for the identity-weighted specification.

The beta method and first-stage SDF-GMM use different regressors, but they are both asking a cross-sectional asset pricing question: can a small set of factor-related quantities explain the average excess returns of the test assets?

First-stage SDF-GMM uses products \(R_i^e f_j\) as regressors. Each regressor measures how a portfolio return co-moves with a factor in the sample. The fitted values answer the same practical question as the beta method: given the factor information, what average excess return does the model assign to each portfolio?

The same SDF-GMM machinery can be run quickly for the two comparison models in the roadmap. The FF3-plus-momentum model adds the momentum factor to the same sample. The FF5 model uses the five-factor data and therefore starts later.

FF3 remains the detailed benchmark because it is the clearest bridge from the beta-method to SDF-GMM. The momentum and FF5 specifications are used as comparisons. Their role is to show how the same GMM logic changes when the factor set changes. The comparison is useful, but it should be read with care: adding a factor changes the model, and the FF5 data also change the available sample window.

Code
sdf_ff4_first_stage <- fit_sdf_gmm_first_stage(
  portfolio_factor_data,
  c("Mkt_RF", "SMB3", "HML", "Mom"),
  "FF3 plus momentum"
)
sdf_ff4_second_stage <- fit_sdf_gmm_second_stage(sdf_ff4_first_stage)
sdf_ff4_j_tests <- gmm_j_test_table(sdf_ff4_first_stage, sdf_ff4_second_stage)

sdf_ff5_first_stage <- fit_sdf_gmm_first_stage(
  portfolio_all_factor_data,
  c("Mkt_RF", "SMB5", "HML", "RMW", "CMA"),
  "FF5"
)
sdf_ff5_second_stage <- fit_sdf_gmm_second_stage(sdf_ff5_first_stage)
sdf_ff5_j_tests <- gmm_j_test_table(sdf_ff5_first_stage, sdf_ff5_second_stage)

sdf_gmm_model_tests <- bind_rows(
  sdf_ff3_j_tests,
  sdf_ff4_j_tests,
  sdf_ff5_j_tests
)

sdf_gmm_model_tests |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "SDF-GMM tests across FF3, FF3 plus momentum, and FF5.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
SDF-GMM tests across FF3, FF3 plus momentum, and FF5.
model stage months n_assets n_factors df identity_criterion j_statistic p_value
FF3 First-stage GMM (W = I) 1128 25 3 22 0.075147 80.60545 0.000000
FF3 Second-stage GMM (W = S^-1) 1128 25 3 22 0.097174 78.38851 0.000000
FF3 plus momentum First-stage GMM (W = I) 1128 25 4 21 0.032272 45.65241 0.001421
FF3 plus momentum Second-stage GMM (W = S^-1) 1128 25 4 21 0.409426 42.83898 0.003296
FF5 First-stage GMM (W = I) 690 25 5 20 0.015023 61.61246 0.000004
FF5 Second-stage GMM (W = S^-1) 690 25 5 20 0.051550 54.59986 0.000047
Code
sdf_gmm_coefficients <- bind_rows(
  sdf_ff3_first_stage$coefficient_table,
  sdf_ff3_second_stage$coefficient_table,
  sdf_ff4_first_stage$coefficient_table,
  sdf_ff4_second_stage$coefficient_table,
  sdf_ff5_first_stage$coefficient_table,
  sdf_ff5_second_stage$coefficient_table
)

sdf_gmm_coefficients |>
  mutate(across(where(is.numeric), ~ round(.x, 6))) |>
  kable(
    caption = "SDF-GMM loadings across the benchmark and comparison models.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
SDF-GMM loadings across the benchmark and comparison models.
model stage factor estimate std_error t_statistic
FF3 First-stage GMM (W = I) Mkt_RF 2.040156 0.633179 3.222085
FF3 First-stage GMM (W = I) SMB3 -0.293621 0.946250 -0.310300
FF3 First-stage GMM (W = I) HML 1.842783 0.839478 2.195152
FF3 Second-stage GMM (W = S^-1) Mkt_RF 1.907719 0.608701 3.134084
FF3 Second-stage GMM (W = S^-1) SMB3 0.981290 0.878460 1.117057
FF3 Second-stage GMM (W = S^-1) HML 1.384296 0.818106 1.692073
FF3 plus momentum First-stage GMM (W = I) Mkt_RF 5.163040 1.571232 3.285982
FF3 plus momentum First-stage GMM (W = I) SMB3 0.656010 1.514500 0.433153
FF3 plus momentum First-stage GMM (W = I) HML 9.867654 3.567355 2.766098
FF3 plus momentum First-stage GMM (W = I) Mom 15.480929 6.869332 2.253630
FF3 plus momentum Second-stage GMM (W = S^-1) Mkt_RF 4.923346 0.870037 5.658776
FF3 plus momentum Second-stage GMM (W = S^-1) SMB3 0.996144 1.204634 0.826927
FF3 plus momentum Second-stage GMM (W = S^-1) HML 8.279121 1.669757 4.958278
FF3 plus momentum Second-stage GMM (W = S^-1) Mom 16.477463 2.653309 6.210157
FF5 First-stage GMM (W = I) Mkt_RF 4.222723 1.233189 3.424230
FF5 First-stage GMM (W = I) SMB5 4.975657 1.526267 3.260018
FF5 First-stage GMM (W = I) HML -2.128367 4.151163 -0.512716
FF5 First-stage GMM (W = I) RMW 12.312729 3.929934 3.133063
FF5 First-stage GMM (W = I) CMA 11.583374 8.206800 1.411436
FF5 Second-stage GMM (W = S^-1) Mkt_RF 5.113191 1.155828 4.423834
FF5 Second-stage GMM (W = S^-1) SMB5 4.848905 1.456827 3.328402
FF5 Second-stage GMM (W = S^-1) HML 1.506782 3.684959 0.408901
FF5 Second-stage GMM (W = S^-1) RMW 18.225587 3.257656 5.594693
FF5 Second-stage GMM (W = S^-1) CMA 8.034860 7.261619 1.106483

The comparison table should be read by model and by stage. Moving from FF3 to FF3 plus momentum changes the factor set while keeping the same test assets and sample. Moving to FF5 changes both the factor set and the sample window. The J-test p-value is the formal overidentifying-restrictions evidence for each SDF specification. The coefficient table then shows which SDF loadings drive those results.

The numerical message is clear. For FF3, the second-stage J-statistic is 78.39 with 22 degrees of freedom and a p-value of <0.001. Adding momentum lowers the second-stage J-statistic to 42.84, with a p-value of 0.003. That improvement is economically useful, but the formal test still rejects the joint pricing restrictions. FF5 also rejects in its shorter sample, with a second-stage p-value of <0.001. The conclusion is that all three SDF specifications leave statistically meaningful joint pricing errors for the 25 size-value portfolios.

The purpose of the comparison is diagnostic. If a richer factor set reduces pricing errors and weakens the J-test evidence against the model, then the additional factors are helping with this cross-section. If the pricing errors remain large or the J-test p-value remains small, then the extra factors have limited explanatory power for these test assets in this sample. The coefficient table then helps identify whether the added factor receives a meaningful SDF loading or enters weakly.

Code
comparison_methods <- bind_rows(
  cross_section_aug |>
    transmute(
      symbol,
      method = "Beta-method cross-section",
      observed_percent = ERe_percent,
      fitted_percent = .fitted,
      pricing_error_percent = ERe_percent - .fitted
    ),
  sdf_ff3_first_stage$fit_table |>
    transmute(
      symbol,
      method = "First-stage SDF-GMM",
      observed_percent,
      fitted_percent,
      pricing_error_percent
    ),
  sdf_ff3_second_stage$fit_table |>
    transmute(
      symbol,
      method = "Second-stage SDF-GMM",
      observed_percent,
      fitted_percent,
      pricing_error_percent
    )
) |>
  left_join(portfolio_label_tbl, by = "symbol")

method_error_summary <- comparison_methods |>
  group_by(method) |>
  summarise(
    rmse_percent = sqrt(mean(pricing_error_percent^2)),
    mae_percent = mean(abs(pricing_error_percent)),
    max_abs_error_percent = max(abs(pricing_error_percent)),
    .groups = "drop"
  )

method_error_summary |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(
    caption = "Pricing-error summary for the FF3 beta-method and SDF-GMM estimates.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Pricing-error summary for the FF3 beta-method and SDF-GMM estimates.
method rmse_percent mae_percent max_abs_error_percent
Beta-method cross-section 0.1138 0.0864 0.2719
First-stage SDF-GMM 0.1632 0.1212 0.5231
Second-stage SDF-GMM 0.1856 0.1121 0.6858
Code
comparison_methods |>
  mutate(
    method = recode(
      method,
      "Beta-method cross-section" = "Beta",
      "First-stage SDF-GMM" = "SDF 1",
      "Second-stage SDF-GMM" = "SDF 2"
    )
  ) |>
  filter(
    symbol %in% (
      comparison_methods |>
        distinct(symbol) |>
        arrange(symbol) |>
        slice(c(1:5, (n() - 4):n())) |>
        pull(symbol)
    )
  ) |>
  mutate(method = factor(method, levels = c("Beta", "SDF 1", "SDF 2"))) |>
  select(symbol, method, observed_percent, fitted_percent, pricing_error_percent) |>
  arrange(symbol, method) |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(
    caption = "Selected FF3 portfolio comparisons by pricing method.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Selected FF3 portfolio comparisons by pricing method.
symbol method observed_percent fitted_percent pricing_error_percent
BIG HiBM Beta 0.9289 0.9012 0.0276
BIG HiBM SDF 1 0.9289 1.0953 -0.1664
BIG HiBM SDF 2 0.9289 1.0462 -0.1173
BIG LoBM Beta 0.6733 0.6102 0.0630
BIG LoBM SDF 1 0.6733 0.5881 0.0852
BIG LoBM SDF 2 0.6733 0.5936 0.0797
ME1 BM2 Beta 0.7150 0.9455 -0.2305
ME1 BM2 SDF 1 0.7150 0.9593 -0.2442
ME1 BM2 SDF 2 0.7150 1.1353 -0.4203
ME1 BM3 Beta 1.0120 1.0306 -0.0186
ME1 BM3 SDF 1 1.0120 1.0094 0.0026
ME1 BM3 SDF 2 1.0120 1.1385 -0.1265
ME1 BM4 Beta 1.1674 1.1483 0.0191
ME1 BM4 SDF 1 1.1674 0.9645 0.2029
ME1 BM4 SDF 2 1.1674 1.0898 0.0776
ME5 BM2 Beta 0.6291 0.7415 -0.1124
ME5 BM2 SDF 1 0.6291 0.6421 -0.0131
ME5 BM2 SDF 2 0.6291 0.6300 -0.0009
ME5 BM3 Beta 0.7176 0.8520 -0.1343
ME5 BM3 SDF 1 0.7176 0.7250 -0.0074
ME5 BM3 SDF 2 0.7176 0.6940 0.0237
ME5 BM4 Beta 0.6341 0.9060 -0.2719
ME5 BM4 SDF 1 0.6341 0.8791 -0.2450
ME5 BM4 SDF 2 0.6341 0.8408 -0.2067
SMALL HiBM Beta 1.3264 1.2245 0.1019
SMALL HiBM SDF 1 1.3264 1.1018 0.2246
SMALL HiBM SDF 2 1.3264 1.2186 0.1078
SMALL LoBM Beta 0.6080 0.8166 -0.2086
SMALL LoBM SDF 1 0.6080 1.1312 -0.5231
SMALL LoBM SDF 2 0.6080 1.2939 -0.6858

The FF3 comparison keeps the beta-method in the chapter. It is the expected-return beta regression from Cochrane chapter 12. First-stage SDF-GMM is the identity-weighted moment regression from Cochrane chapter 13. Second-stage SDF-GMM is the covariance-weighted version. The error-summary table shows which method leaves smaller pricing errors by RMSE, MAE, and maximum absolute error. The selected-portfolio table makes the comparison auditable at the portfolio level.

For these FF3 estimates, the beta-method has the lowest RMSE: 0.1138 percent per month. First-stage SDF-GMM has RMSE 0.1632, and second-stage SDF-GMM has RMSE 0.1856. The MAE comparison is closer, but the same table shows that the SDF versions produce larger maximum absolute errors in this sample. The empirical lesson is therefore specific: the SDF-GMM framework gives the formal moment test and the Cochrane-style weighting logic, while the static beta-method gives the smaller unweighted fitted-return errors for the FF3 cross-section used here.

Code
method_limits <- range(
  c(comparison_methods$observed_percent, comparison_methods$fitted_percent),
  na.rm = TRUE
) * c(0.95, 1.05)

comparison_methods |>
  ggplot(aes(fitted_percent, observed_percent)) +
  geom_abline(slope = 1, intercept = 0, linetype = 2, color = "#D55E00", linewidth = 0.8) +
  geom_text(aes(label = symbol), size = 2.1) +
  facet_wrap(~ method) +
  coord_fixed(xlim = method_limits, ylim = method_limits, clip = "off") +
  labs(
    x = "Fitted average excess return (%)",
    y = "Observed average excess return (%)"
  ) +
  theme_minimal(base_size = 12) +
  theme(plot.margin = margin(10, 30, 10, 10))
Figure 4.31: Observed and fitted average excess returns across FF3 pricing methods.

The panel comparison gives the cleanest visual summary of the three methods. Each panel uses the same axes. Points closer to the 45-degree line are better priced by that method. Differences across panels show how the fitted cross-section changes when the model moves from beta pricing to first-stage and second-stage SDF-GMM.

The panels should be read as a model comparison with a common scale. A method that compresses points away from the 45-degree line underfits the dispersion in average returns. A method that places many points above or below the line in a similar direction leaves a systematic bias. A method with a few isolated large gaps may be capturing the broad pattern while struggling with particular size-value cells.

Code
portfolio_order <- portfolio_label_tbl |>
  arrange(Size, Value) |>
  pull(symbol)

comparison_methods |>
  mutate(symbol = factor(symbol, levels = portfolio_order)) |>
  ggplot(aes(symbol, pricing_error_percent, color = method)) +
  geom_hline(yintercept = 0, color = "gray60", linewidth = 0.5) +
  geom_point(size = 2.1, position = position_dodge(width = 0.55)) +
  coord_flip() +
  labs(
    x = "Portfolio",
    y = "Pricing error (%)",
    color = NULL
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")
Figure 4.32: Pricing errors by portfolio across FF3 pricing methods.

The pricing-error plot reads the same information from a different angle. The zero line is perfect pricing. Points far from zero identify portfolios that remain difficult for a method. The grouped dots also show whether first-stage and second-stage SDF-GMM move errors in the same direction or change the portfolio-level pattern.

This plot is especially useful after the fitted-return panels. The fitted panels show distance from the 45-degree line. The error plot turns that distance into signed errors. Positive errors mean observed returns exceed fitted returns; negative errors mean fitted returns exceed observed returns. Reading the signs portfolio by portfolio helps identify whether a method consistently underprices or overprices particular size-value groups.

4.10 Pricing errors by size and value

A factor model can fit the cross-section well on average and still leave a visible pattern in the errors. Cochrane emphasizes this diagnostic in tests of characteristics: after a model is estimated, the remaining pricing errors should be inspected against the characteristics used to form the test assets (Cochrane 2005). In this chapter the relevant characteristics are size and book-to-market.

The pricing error is

\[ \widehat{\eta}_i = \bar{R}_i^e - \widehat{\bar{R}}_i^e. \]

A positive value means that the portfolio earned more than the fitted value assigned by the model. A negative value means that the fitted value was higher than the observed average return. If the positive and negative errors cluster by size or value, the model still leaves a characteristic pattern unexplained.

This diagnostic connects the regression evidence back to the portfolio construction. The 25 portfolios were formed by sorting stocks into size and book-to-market cells. Those characteristics are visible in the labels before any model is estimated. A successful factor model should reduce the return pattern associated with those labels. After estimation, the heatmap asks a simple question: do the remaining errors still line up with the original size-value grid?

The answer guides interpretation. A few large errors scattered across the grid suggest asset-specific problems or noisy estimates. A block of errors in one row or one column suggests that the model has left a size or value pattern unexplained. This is why the diagnostic is more informative than a single goodness-of-fit number: it shows the location of the model’s failures.

Code
pricing_error_long <- comparison_methods |>
  transmute(
    symbol,
    Size,
    Value,
    method,
    pricing_error = pricing_error_percent
  )

pricing_error_long |>
  group_by(method) |>
  slice_max(abs(pricing_error), n = 5, with_ties = FALSE) |>
  ungroup() |>
  arrange(method, desc(abs(pricing_error))) |>
  mutate(pricing_error = round(pricing_error, 4)) |>
  kable(
    caption = "Largest absolute pricing errors by method.",
    row.names = FALSE
  ) |>
  kable_styling(latex_options = "HOLD_position")
Largest absolute pricing errors by method.
symbol Size Value method pricing_error
ME5 BM4 Big 4 Beta-method cross-section -0.2719
ME1 BM2 Small 2 Beta-method cross-section -0.2305
SMALL LoBM Small Low Beta-method cross-section -0.2086
ME4 BM1 4 Low Beta-method cross-section 0.1656
ME2 BM5 2 High Beta-method cross-section 0.1455
SMALL LoBM Small Low First-stage SDF-GMM -0.5231
ME5 BM4 Big 4 First-stage SDF-GMM -0.2450
ME1 BM2 Small 2 First-stage SDF-GMM -0.2442
SMALL HiBM Small High First-stage SDF-GMM 0.2246
ME1 BM4 Small 4 First-stage SDF-GMM 0.2029
SMALL LoBM Small Low Second-stage SDF-GMM -0.6858
ME1 BM2 Small 2 Second-stage SDF-GMM -0.4203
ME2 BM1 2 Low Second-stage SDF-GMM -0.2502
ME5 BM4 Big 4 Second-stage SDF-GMM -0.2067
ME1 BM3 Small 3 Second-stage SDF-GMM -0.1265

The table identifies the portfolios where each method has the largest absolute pricing errors. It is a targeted audit: these are the cells that deserve attention before saying that a model explains the 25-portfolio cross-section.

Code
pricing_error_long |>
  ggplot(aes(Value, Size, fill = pricing_error)) +
  geom_tile(color = "white", linewidth = 0.7) +
  geom_text(aes(label = scales::number(pricing_error, accuracy = 0.01)), size = 3) +
  facet_wrap(~ method) +
  scale_fill_gradient2(
    low = "#2C7FB8",
    mid = "white",
    high = "#D55E00",
    midpoint = 0,
    labels = scales::number_format(accuracy = 0.01)
  ) +
  labs(
    x = "Book-to-market portfolio",
    y = "Size portfolio",
    fill = "Pricing error (%)"
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "right")
Figure 4.33: Pricing errors across the size-value grid.

The heatmap makes the characteristic diagnostic visible. A random-looking mix of small positive and negative errors would be favorable for the model. Blocks of similar color along the size or value dimension indicate that the model is still missing a systematic pattern associated with the portfolio construction. This diagnostic is useful because the 25 portfolios were formed precisely to make those characteristic patterns visible.

Read one heatmap cell as follows. The row gives the size group, the column gives the book-to-market group, and the number inside the cell gives the pricing error in monthly percent units. Orange cells have observed average returns above the fitted value. Blue cells have fitted values above observed average returns. A useful model should make the heatmap visually quiet: small numbers, weak color, and absence of a clear row or column pattern. A model that leaves a strong color block is telling the reader where its factor structure is incomplete for this cross-section.

4.11 Implementation checks and econometric limits

The main implementation risk in factor models is losing the connection between units, equations, and samples. These checks keep the workflow auditable:

Check Why it is important
Decimal returns in estimation Regression coefficients assume returns and factors are in the same units.
Month-end dates Asset returns and factors must align on the same calendar month.
Excess returns Test-asset returns must subtract the risk-free rate before estimation.
Sample windows FF3, momentum, and FF5 have different starting dates.
Intercepts Alphas are pricing errors after controlling for the chosen factors.
Betas Betas are exposures estimated from time-series regressions.
Cross-section The second-pass regression tests whether estimated exposures explain average returns.
Pricing errors Remaining errors should be read against the characteristics used to form the portfolios.

The chapter deliberately keeps the code close to the equations. The data download creates factor and portfolio returns. The time-series regressions estimate \(\alpha_i\) and \(\boldsymbol{\beta}_i\). The fitted expected-return equation converts those estimates into model-implied average excess returns. The cross-sectional beta regression tests whether factor exposures explain the 25 portfolio averages. The SDF-GMM blocks test whether return-factor moments price the same portfolios under first-stage and second-stage weighting. The final characteristic diagnostic asks where the remaining errors are located.

The sequence can be read as a translation table from Cochrane-style notation to applied work in R:

Econometric idea Question in words Where the chapter answers it
Factor exposure How sensitive is each test asset to common risk factors? Time-series regressions and beta tables.
Expected-return pricing Do those exposures explain average returns across assets? Cross-sectional beta-method regression.
SDF moment condition Can one discount factor make all excess returns average to zero? First-stage and second-stage SDF-GMM.
Overidentification Are the remaining moment errors statistically too large? J-test tables.
Economic diagnosis Which portfolios remain hard to price, and why might that pattern be meaningful? Fitted-return plots, error tables, and size-value heatmaps.

This translation is the main reason for writing the chapter in this style. A reader should be able to move from an equation to a code object, from the code object to a table or figure, and from the table or figure to an economic interpretation.

The same workflow has important econometric limits:

Limit Consequence for interpretation
Static two-pass regression The chapter estimates one cross-section of average returns. A formal Fama-MacBeth analysis would estimate prices of risk period by period.
Estimated betas The second-pass regressors are generated from first-pass regressions. Formal inference would account for that extra uncertainty.
Alpha inference The chapter reads alphas economically and diagnostically. A formal joint test of alphas, such as a GRS-style test, is a natural extension.
SDF-GMM weighting The chapter implements first-stage identity weighting and second-stage covariance weighting with a zero-lag moment covariance matrix. HAC or iterated GMM would be natural extensions.
J-test The J-test is reported for the SDF-GMM moments. Its interpretation relies on the large-sample chi-square approximation.
Standard errors The displayed OLS and GMM tables are useful for orientation. Strong inference in financial returns often requires heteroskedasticity and autocorrelation corrections.
Data source Downloaded financial data can change because of vendor corrections, ticker history, or file updates. The fixed sample end makes the exercise easier to audit.

These limits define the chapter’s scope: build the bridge from factor-model equations to R code, fitted returns, pricing errors, and economic interpretation.

4.12 Conclusion

This chapter extends the book from forecasting and volatility into asset pricing econometrics. The object of interest is an estimated relation between average excess returns and systematic risk exposures.

The empirical logic has five layers. First, factor returns define candidate sources of systematic risk. Second, time-series regressions estimate each asset’s exposure to those factors. Third, cross-sectional regressions evaluate whether those exposures explain average returns across test assets. Fourth, SDF-GMM writes the same pricing problem as moment restrictions, estimates first-stage and second-stage SDF loadings, and tests overidentifying restrictions. Fifth, pricing-error diagnostics show where the model still leaves structure in the size-value grid.

The Fama-French chapter also illustrates a broader lesson for applied financial econometrics: a model is useful only when the equation, data construction, estimation code, and economic interpretation remain connected.