Replicating Fama & French (1993): Common Risk Factors in the Returns on Stocks and Bonds¶
U.S.E. Finance Data Hub / WRDS Database: Fama-French Portfolios and Factors¶
Paper replicated: Fama, E. F. & French, K. R. (1993). Common Risk Factors in the Returns on Stocks and Bonds. Journal of Financial Economics, 33(1), 3–56.
Database used: Fama-French Portfolios and Factors (Data Hub guide)
What this notebook does¶
Fama & French (1993) is arguably the most influential paper in empirical asset pricing, with over 30,000 citations. The authors show that three factors (the market, a size factor SMB, and a value factor HML) explain the cross-section of expected stock returns much better than the market factor alone (the CAPM). The paper also extends the analysis to bond markets with two additional factors (term and default risk).
The WRDS Fama-French database provides the pre-computed factor returns and test portfolios, making this the most straightforward replication of all 15 notebooks. Students can reproduce the paper's key time-series regressions by simply downloading the factors and the 25 size/value-sorted portfolios.
- Pull monthly factor returns (Mkt-Rf, SMB, HML) from Fama-French (
ff_all.factors_monthly) via the WRDS API - Pull the 25 size/value-sorted portfolio returns from
ff_all.portfolios25 - Run time-series regressions of each portfolio's excess return on the three factors
- Examine the pattern of alphas, betas, and R-squared values across the 25 portfolios
- Compare our results to the published 1993 findings
Learning objectives¶
- Practice connecting to WRDS and querying the Fama-French database
- Understand the structure of the three-factor model and what SMB and HML capture
- Learn how to run and interpret time-series regressions in an asset pricing context
- Build intuition for why size and value premiums exist and how they are measured
Requirements to run this notebook¶
- A valid WRDS account with Fama-French access (ask the Finance Data Hub if you don't have one yet)
pip install wrds pandas numpy matplotlib seaborn statsmodels- You will be prompted for your WRDS username/password the first time you connect (or set up a
.pgpassfile, see the WRDS Python guide)
1. Setup and WRDS connection¶
import wrds
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.api as sm
sns.set_theme(style="whitegrid", palette="deep")
pd.set_option("display.max_columns", 50)
db = wrds.Connection()
2. Identifying the Fama-French variables we need¶
The Fama-French database on WRDS uses the ff_all schema. Two tables are central to this replication.
Factor returns (ff_all.factors_monthly) provide the three factors plus momentum.
| Factor | Description |
|---|---|
mktrf |
Market return minus the risk-free rate |
smb |
Small Minus Big (size factor) |
hml |
High Minus Low (value factor) |
rf |
Risk-free rate (1-month T-bill) |
mom |
Momentum factor (not in the original 1993 paper but available) |
25 portfolios (ff_all.portfolios25) provide monthly returns for portfolios double-sorted on size (market equity) and book-to-market ratio, arranged in a 5x5 grid.
The factors are expressed in percentage points (e.g., 0.50 means 0.50%, not 50%).
# Pull monthly three-factor returns
factors = db.raw_sql("""
SELECT date, mktrf, smb, hml, rf
FROM ff_all.factors_monthly
WHERE date >= '1963-07-01'
""", date_cols=["date"])
print(f"Factor data: {len(factors):,} months")
print(f"Date range: {factors['date'].min()} to {factors['date'].max()}")
# Pull the 25 size/book-to-market sorted portfolios
port25 = db.raw_sql("""
SELECT *
FROM ff_all.portfolios25
WHERE date >= '1963-07-01'
""", date_cols=["date"])
print(f"\nPortfolio data: {len(port25):,} months")
print("Columns:", port25.columns.tolist())
3. Cleaning the sample¶
print("Factors date sample:", factors["date"].head())
print("Portfolios date sample:", port25["date"].head())
print("Factors dtype:", factors["date"].dtype)
print("Portfolios dtype:", port25["date"].dtype)
# Align on year-month since factors use start-of-month dates
# and portfolios use end-of-month dates
factors["ym"] = factors["date"].dt.to_period("M")
port25["ym"] = port25["date"].dt.to_period("M")
merged = port25.merge(factors, on="ym", how="inner", suffixes=("_port", "_fac"))
# Identify the 25 portfolio return columns
exclude = {"date_port", "date_fac", "ym", "mktrf", "smb", "hml", "rf"}
port_cols = [c for c in merged.columns if c not in exclude]
print(f"Merged dataset: {len(merged):,} months")
print(f"Portfolio columns ({len(port_cols)}): {port_cols[:5]}... (showing first 5)")
print(f"\nMissing values in factors: {merged[['mktrf','smb','hml']].isnull().sum().sum()}")
4. Running the three-factor time-series regressions¶
The Fama-French three-factor model says that the excess return on any portfolio $i$ is explained by three systematic risk factors:
$$R_{i,t} - R_{f,t} = \alpha_i + \beta_{i,1}(R_{m,t} - R_{f,t}) + \beta_{i,2} SMB_t + \beta_{i,3} HML_t + \epsilon_{i,t}$$
For each of the 25 portfolios, we run this regression and collect the intercept ($\alpha$), the three betas, and the $R^2$.
If the model is correct, all 25 alphas should be indistinguishable from zero (the three factors fully explain expected returns). The key test is whether the GRS F-statistic (Gibbons, Ross, Shanken 1989) rejects the null that all alphas are jointly zero.
# Run time-series regressions for each of the 25 portfolios
# Convert nullable Float64 columns to standard float64 for statsmodels
for col_name in ["mktrf", "smb", "hml", "rf"]:
merged[col_name] = merged[col_name].astype("float64")
results = []
for col in port_cols:
merged[col] = merged[col].astype("float64")
y = merged[col] - merged["rf"] # Excess return
X = sm.add_constant(merged[["mktrf", "smb", "hml"]].astype("float64"))
# Drop any rows with NaN
mask = y.notna() & X.notna().all(axis=1)
if mask.sum() < 60:
continue
model = sm.OLS(y[mask].values, X[mask].values).fit()
results.append({
"portfolio": col,
"alpha": model.params[0],
"alpha_t": model.tvalues[0],
"beta_mkt": model.params[1],
"beta_smb": model.params[2],
"beta_hml": model.params[3],
"r_squared": model.rsquared
})
reg_df = pd.DataFrame(results)
print(f"Regressions run: {len(reg_df)}")
print(f"\nMean R-squared: {reg_df['r_squared'].mean():.3f}")
print(f"Min R-squared: {reg_df['r_squared'].min():.3f}")
print(f"Max R-squared: {reg_df['r_squared'].max():.3f}")
print(f"\nMean absolute alpha: {reg_df['alpha'].abs().mean():.4f}")
print(f"Alphas significant at 5% (|t| > 1.96): {(reg_df['alpha_t'].abs() > 1.96).sum()}")
5. Visualizing the results¶
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# R-squared distribution across the 25 portfolios
axes[0].bar(range(len(reg_df)), reg_df["r_squared"].sort_values(ascending=False).values,
color="steelblue")
axes[0].set_title("R-Squared of Three-Factor Model\nAcross 25 Size/Value Portfolios")
axes[0].set_xlabel("Portfolio (sorted by R-squared)")
axes[0].set_ylabel("R-squared")
axes[0].axhline(reg_df["r_squared"].mean(), color="red", linestyle="--",
label=f"Mean: {reg_df['r_squared'].mean():.3f}")
axes[0].legend()
# Alpha distribution
axes[1].bar(range(len(reg_df)), reg_df["alpha"].sort_values().values,
color=["firebrick" if a < 0 else "seagreen" for a in reg_df["alpha"].sort_values().values])
axes[1].set_title("Intercepts (Alphas) from Three-Factor Regressions")
axes[1].set_xlabel("Portfolio (sorted by alpha)")
axes[1].set_ylabel("Alpha (% per month)")
axes[1].axhline(0, color="black", linestyle="-", linewidth=0.5)
plt.tight_layout()
plt.show()
# Cumulative factor returns
factors_sorted = factors.sort_values("date")
factors_sorted["cum_mkt"] = (1 + factors_sorted["mktrf"] / 100).cumprod()
factors_sorted["cum_smb"] = (1 + factors_sorted["smb"] / 100).cumprod()
factors_sorted["cum_hml"] = (1 + factors_sorted["hml"] / 100).cumprod()
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(factors_sorted["date"], factors_sorted["cum_mkt"], label="Market (Mkt-Rf)", color="steelblue")
ax.plot(factors_sorted["date"], factors_sorted["cum_smb"], label="Size (SMB)", color="darkorange")
ax.plot(factors_sorted["date"], factors_sorted["cum_hml"], label="Value (HML)", color="seagreen")
ax.set_title("Cumulative Factor Returns (1963-present)")
ax.set_ylabel("Growth of $1")
ax.set_xlabel("Date")
ax.legend()
ax.set_yscale("log")
plt.tight_layout()
plt.show()
6. Comparing to Fama & French (1993): What's the same, what's different¶
What replicates cleanly
This is the cleanest replication of all 15 notebooks because the WRDS Fama-French database provides exactly the data used in the paper: the three factor returns and the 25 test portfolios. The time-series regressions should produce R-squared values above 0.90 for most portfolios (the paper reports an average of about 0.93), confirming that the three factors capture the vast majority of variation in portfolio returns. The pattern of SMB betas (increasing from large-cap to small-cap portfolios) and HML betas (increasing from growth to value portfolios) should be clearly visible and match Table 6 of the original paper.
Where a modern WRDS-based replication necessarily differs from the original
Sample period. The original paper covers July 1963 to December 1991. Our sample extends to the present, which includes periods where the value premium (HML) has been much weaker (2010s growth outperformance) and the size premium (SMB) has been inconsistent. The cumulative factor return plot should show these regime changes clearly.
Factor construction. The WRDS Fama-French data is sourced from Kenneth French's website, which updates the factor returns continuously. The methodology has been slightly refined over the years (e.g., using NYSE breakpoints only for portfolio sorts), but the core construction is the same.
Five-factor model. Fama & French subsequently published a five-factor model (2015) adding profitability (RMW) and investment (CMA) factors. The WRDS database provides these as well in
ff_all.fivefactors_monthly. Extending the regressions to five factors is a natural next step.Bond factors. The original 1993 paper also includes bond market factors (term premium and default premium). We focus on the equity side, but students can extend the analysis by adding bond factors or using the bond return data from the WRDS Bond Returns database.
References¶
- Fama, E. F. & French, K. R. (1993). Common Risk Factors in the Returns on Stocks and Bonds. Journal of Financial Economics, 33(1), 3–56.
- WRDS Fama-French guide: https://uufinance.github.io/data/wrds/databases/fama-french/
- WRDS Fama-French access page: https://wrds-www.wharton.upenn.edu/pages/get-data/fama-french-portfolios-and-factors/
- WRDS Python/API setup guide: https://uufinance.github.io/data/wrds/notebook/
Prepared for the U.S.E. Finance Data Hub as a database-tutorial template.