Fama-French 3-Factor Analysis of Investment Portfolios#
Introduction#
In the previous notebook (06_CAPM_analysis), we observed that CAPM cannot fully explain the cross-section of portfolio returns. Some portfolios show significant alpha (abnormal returns) that CAPM’s single market factor cannot capture.
This notebook applies the Fama-French 3-Factor Model to the investment portfolios to test whether adding size (SMB) and value (HML) factors can better explain portfolio returns and reduce the unexplained alpha.
The Fama-French 3-Factor Model#
The FF3 model extends CAPM by adding two factors:
Where:
Mkt-RF: Market excess return (same as CAPM)
SMB (Small Minus Big): Return of small-cap stocks minus large-cap stocks
HML (High Minus Low): Return of value stocks (high B/M) minus growth stocks (low B/M)
Learning Objectives#
Understand the Fama-French 3-factor model
Run 3-factor regressions using the
finmpackageCompare results with CAPM regressions
Interpret factor loadings (betas on SMB and HML)
Assess whether the 3-factor model explains investment portfolio returns
import pandas as pd
import numpy as np
import finm
from settings import config
DATA_DIR = config("DATA_DIR")
Step 1: Load Data#
We load:
univ_3_inv_vwret.parquet: 3 investment portfolio returns (Low, Medium, High)
FF_FACTORS.parquet: Fama-French factors (Mkt-RF, SMB, HML, RF)
# Load the 3 investment portfolios
inv_portfolios = pd.read_parquet(f"{DATA_DIR}/univ_3_inv_vwret.parquet")
print(f"Investment portfolios shape: {inv_portfolios.shape}")
print(f"Portfolio codes: {inv_portfolios['inv_port'].unique()}")
# Pivot to wide format
inv_wide = inv_portfolios.pivot(index="jdate", columns="inv_port", values="vwret_inv")
inv_wide.columns.name = None
inv_wide.head()
Investment portfolios shape: (2070, 3)
Portfolio codes: ['High' 'Low' 'Medium']
| High | Low | Medium | |
|---|---|---|---|
| jdate | |||
| 1967-07-31 | 0.060813 | 0.058490 | 0.034118 |
| 1967-08-31 | -0.004922 | -0.018276 | -0.007149 |
| 1967-09-30 | 0.023649 | 0.017793 | 0.038591 |
| 1967-10-31 | -0.019926 | -0.036670 | -0.027705 |
| 1967-11-30 | 0.006494 | -0.005037 | 0.011197 |
# Load Fama-French factors
factors = pd.read_parquet(f"{DATA_DIR}/FF_FACTORS.parquet")
# Rename columns to standard names used by finm package
factors = factors.rename(columns={
"date": "jdate",
"mktrf": "Mkt-RF",
"smb": "SMB",
"hml": "HML",
"rf": "RF",
})
# Convert nullable Float64 to numpy float64 for compatibility
for col in ["Mkt-RF", "SMB", "HML", "RF"]:
factors[col] = factors[col].astype("float64")
factors = factors.set_index("jdate")
print(f"\nFactors shape: {factors.shape}")
print(f"Columns: {factors.columns.tolist()}")
factors.head()
Factors shape: (1193, 8)
Columns: ['Mkt-RF', 'SMB', 'HML', 'RF', 'year', 'month', 'umd', 'dateff']
| Mkt-RF | SMB | HML | RF | year | month | umd | dateff | |
|---|---|---|---|---|---|---|---|---|
| jdate | ||||||||
| 1926-07-31 | 0.0289 | -0.0255 | -0.0239 | 0.0022 | 1926.0 | 7.0 | <NA> | 1926-07-31 |
| 1926-08-31 | 0.0264 | -0.0114 | 0.0381 | 0.0025 | 1926.0 | 8.0 | <NA> | 1926-08-31 |
| 1926-09-30 | 0.0038 | -0.0136 | 0.0005 | 0.0023 | 1926.0 | 9.0 | <NA> | 1926-09-30 |
| 1926-10-31 | -0.0327 | -0.0014 | 0.0082 | 0.0032 | 1926.0 | 10.0 | <NA> | 1926-10-30 |
| 1926-11-30 | 0.0254 | -0.0011 | -0.0061 | 0.0031 | 1926.0 | 11.0 | <NA> | 1926-11-30 |
Step 2: Align Data#
Ensure all data covers the same time period.
# Find common dates
common_dates = inv_wide.index.intersection(factors.index)
print(f"Common dates: {len(common_dates)}")
print(f"Date range: {common_dates.min()} to {common_dates.max()}")
# Align data
inv_aligned = inv_wide.loc[common_dates]
factors_aligned = factors.loc[common_dates]
Common dates: 690
Date range: 1967-07-31 00:00:00 to 2024-12-31 00:00:00
Step 3: Run Fama-French 3-Factor Regressions#
For each investment portfolio, we regress returns on the three factors.
Note that run_fama_french_regression takes raw returns and computes
excess returns internally using the RF column.
# Run FF3 regressions for each portfolio
ff3_results = {}
for portfolio in inv_aligned.columns:
result = finm.run_fama_french_regression(
returns=inv_aligned[portfolio],
factors=factors_aligned,
annualization_factor=12, # Monthly data
)
ff3_results[portfolio] = result
print(f"Completed FF3 regressions for {len(ff3_results)} portfolios")
Completed FF3 regressions for 3 portfolios
Step 4: Create Results Table#
We compile the regression results including:
Alpha (monthly and annualized)
Betas for all three factors
R-squared
# Create summary DataFrame
summary_data = []
for portfolio, result in ff3_results.items():
summary_data.append({
"Portfolio": portfolio,
"Alpha (monthly)": result.alpha,
"Alpha (annual)": result.alpha_annualized,
"Alpha t-stat": result.alpha_tstat,
"Beta (Mkt-RF)": result.betas["Mkt-RF"],
"t(Mkt-RF)": result.beta_tstats["Mkt-RF"],
"Beta (SMB)": result.betas["SMB"],
"t(SMB)": result.beta_tstats["SMB"],
"Beta (HML)": result.betas["HML"],
"t(HML)": result.beta_tstats["HML"],
"R-squared": result.r_squared,
"Adj R-squared": result.adj_r_squared,
"N": result.n_observations,
})
ff3_summary = pd.DataFrame(summary_data)
ff3_summary = ff3_summary.set_index("Portfolio")
# Display formatted results
print("Fama-French 3-Factor Regression Results")
print("=" * 90)
for port in ["Low", "Medium", "High"]:
r = ff3_results[port]
print(f"\n{port} Investment Portfolio:")
print(f" Alpha (annualized): {r.alpha_annualized:>8.2%} (t = {r.alpha_tstat:>5.2f})")
print(f" Beta (Mkt-RF): {r.betas['Mkt-RF']:>8.2f} (t = {r.beta_tstats['Mkt-RF']:>5.2f})")
print(f" Beta (SMB): {r.betas['SMB']:>8.2f} (t = {r.beta_tstats['SMB']:>5.2f})")
print(f" Beta (HML): {r.betas['HML']:>8.2f} (t = {r.beta_tstats['HML']:>5.2f})")
print(f" R-squared: {r.r_squared:>8.2%}")
Fama-French 3-Factor Regression Results
==========================================================================================
Low Investment Portfolio:
Alpha (annualized): 0.57% (t = 0.92)
Beta (Mkt-RF): 0.96 (t = 81.41)
Beta (SMB): 0.03 (t = 1.81)
Beta (HML): 0.19 (t = 11.01)
R-squared: 91.39%
Medium Investment Portfolio:
Alpha (annualized): 0.14% (t = 0.38)
Beta (Mkt-RF): 0.94 (t = 136.62)
Beta (SMB): -0.14 (t = -14.08)
Beta (HML): 0.17 (t = 17.23)
R-squared: 96.57%
High Investment Portfolio:
Alpha (annualized): 0.04% (t = 0.08)
Beta (Mkt-RF): 1.07 (t = 119.40)
Beta (SMB): 0.01 (t = 0.99)
Beta (HML): -0.18 (t = -13.92)
R-squared: 96.15%
Step 5: Compare with CAPM Results#
To understand the impact of adding SMB and HML factors, we also run CAPM regressions and compare the results side-by-side.
# Run CAPM regressions for comparison
rf = factors_aligned["RF"]
mkt_rf = factors_aligned["Mkt-RF"]
excess_returns = inv_aligned.sub(rf, axis=0)
capm_results = {}
for portfolio in inv_aligned.columns:
result = finm.run_capm_regression(
excess_returns=excess_returns[portfolio],
market_excess_returns=mkt_rf,
annualization_factor=12,
)
capm_results[portfolio] = result
# Create comparison table
print("\nComparison: CAPM vs Fama-French 3-Factor Model")
print("=" * 90)
print(f"\n{'Portfolio':<10} | {'CAPM Alpha':<20} | {'FF3 Alpha':<20} | {'CAPM R²':<10} | {'FF3 R²':<10}")
print("-" * 90)
for port in ["Low", "Medium", "High"]:
capm = capm_results[port]
ff3 = ff3_results[port]
capm_sig = "*" if abs(capm.alpha_tstat) > 1.96 else " "
ff3_sig = "*" if abs(ff3.alpha_tstat) > 1.96 else " "
print(f"{port:<10} | {capm.alpha_annualized:>7.2%} (t={capm.alpha_tstat:>5.2f}){capm_sig} | "
f"{ff3.alpha_annualized:>7.2%} (t={ff3.alpha_tstat:>5.2f}){ff3_sig} | "
f"{capm.r_squared:>8.2%} | {ff3.r_squared:>8.2%}")
print("\n* indicates significance at 5% level (|t| > 1.96)")
Comparison: CAPM vs Fama-French 3-Factor Model
==========================================================================================
Portfolio | CAPM Alpha | FF3 Alpha | CAPM R² | FF3 R²
------------------------------------------------------------------------------------------
Low | 1.39% (t= 2.08)* | 0.57% (t= 0.92) | 89.87% | 91.39%
Medium | 0.88% (t= 1.84) | 0.14% (t= 0.38) | 93.86% | 96.57%
High | -0.74% (t=-1.41) | 0.04% (t= 0.08) | 95.04% | 96.15%
* indicates significance at 5% level (|t| > 1.96)
Step 6: Interpretation of Factor Loadings#
The betas on SMB and HML tell us about the portfolio’s exposures:
SMB Beta > 0: Portfolio tilts toward small-cap stocks
SMB Beta < 0: Portfolio tilts toward large-cap stocks
HML Beta > 0: Portfolio tilts toward value stocks (high book-to-market)
HML Beta < 0: Portfolio tilts toward growth stocks (low book-to-market)
These exposures can help explain why investment portfolios earn different returns beyond what the market factor predicts.
print("Factor Loading Analysis")
print("=" * 70)
for port in ["Low", "Medium", "High"]:
r = ff3_results[port]
print(f"\n{port} Investment Portfolio:")
# Interpret SMB loading
smb_beta = r.betas["SMB"]
if smb_beta > 0.1:
smb_interp = "tilts toward SMALL-CAP stocks"
elif smb_beta < -0.1:
smb_interp = "tilts toward LARGE-CAP stocks"
else:
smb_interp = "neutral on size"
# Interpret HML loading
hml_beta = r.betas["HML"]
if hml_beta > 0.1:
hml_interp = "tilts toward VALUE stocks"
elif hml_beta < -0.1:
hml_interp = "tilts toward GROWTH stocks"
else:
hml_interp = "neutral on value/growth"
print(f" SMB Beta = {smb_beta:>6.2f}: {smb_interp}")
print(f" HML Beta = {hml_beta:>6.2f}: {hml_interp}")
Factor Loading Analysis
======================================================================
Low Investment Portfolio:
SMB Beta = 0.03: neutral on size
HML Beta = 0.19: tilts toward VALUE stocks
Medium Investment Portfolio:
SMB Beta = -0.14: tilts toward LARGE-CAP stocks
HML Beta = 0.17: tilts toward VALUE stocks
High Investment Portfolio:
SMB Beta = 0.01: neutral on size
HML Beta = -0.18: tilts toward GROWTH stocks
Step 7: The Investment Anomaly#
Research has shown that firms with low investment (conservative) tend to earn higher returns than firms with high investment (aggressive). This is known as the investment anomaly or asset growth effect.
If the Fama-French 3-factor model fully explains the cross-section of returns, the alphas should be zero. Significant alphas suggest that investment is a priced factor not captured by Mkt-RF, SMB, and HML.
This observation led to the development of the Fama-French 5-Factor Model which adds:
RMW (Robust Minus Weak): Profitability factor
CMA (Conservative Minus Aggressive): Investment factor
# Test for investment anomaly: Is Low - High spread significant?
low_result = ff3_results["Low"]
high_result = ff3_results["High"]
alpha_spread = low_result.alpha_annualized - high_result.alpha_annualized
print("Investment Anomaly Analysis")
print("=" * 70)
print(f"\nLow Investment Alpha (annualized): {low_result.alpha_annualized:>7.2%}")
print(f"High Investment Alpha (annualized): {high_result.alpha_annualized:>7.2%}")
print(f"Alpha Spread (Low - High): {alpha_spread:>7.2%}")
print()
if low_result.alpha_annualized > high_result.alpha_annualized:
print("Result: Conservative investment firms earn higher alpha than aggressive")
print(" investment firms after controlling for market, size, and value factors.")
print(" This suggests the investment factor is not fully captured by FF3.")
else:
print("Result: No clear investment anomaly observed in this sample.")
Investment Anomaly Analysis
======================================================================
Low Investment Alpha (annualized): 0.57%
High Investment Alpha (annualized): 0.04%
Alpha Spread (Low - High): 0.53%
Result: Conservative investment firms earn higher alpha than aggressive
investment firms after controlling for market, size, and value factors.
This suggests the investment factor is not fully captured by FF3.
Step 8: Summary and Conclusions#
Key Findings#
R-squared Improvement: The FF3 model typically explains more variance (higher R-squared) than CAPM by adding size and value factors.
Factor Loadings: Investment portfolios may have systematic exposures to size and value that partially explain their returns.
Residual Alpha: If significant alpha remains after FF3 adjustment, it suggests the investment effect is a distinct source of returns not captured by market, size, and value factors.
Implications for Factor Investing#
The investment anomaly motivates adding CMA (Conservative Minus Aggressive) as a fifth factor in the Fama-French 5-Factor Model
Investors may be able to earn excess returns by tilting toward firms with conservative investment policies
However, transaction costs and implementation considerations must be evaluated before pursuing such strategies
# Final summary table
print("\nFinal Summary Table")
print("=" * 100)
print(f"\n{'Model':<8} | {'Portfolio':<10} | {'Alpha (ann)':<12} | {'t(Alpha)':<10} | "
f"{'Beta(Mkt)':<10} | {'R-squared':<10}")
print("-" * 100)
for port in ["Low", "Medium", "High"]:
capm = capm_results[port]
print(f"{'CAPM':<8} | {port:<10} | {capm.alpha_annualized:>10.2%} | {capm.alpha_tstat:>8.2f} | "
f"{capm.betas['Mkt-RF']:>8.2f} | {capm.r_squared:>8.2%}")
print("-" * 100)
for port in ["Low", "Medium", "High"]:
ff3 = ff3_results[port]
print(f"{'FF3':<8} | {port:<10} | {ff3.alpha_annualized:>10.2%} | {ff3.alpha_tstat:>8.2f} | "
f"{ff3.betas['Mkt-RF']:>8.2f} | {ff3.r_squared:>8.2%}")
Final Summary Table
====================================================================================================
Model | Portfolio | Alpha (ann) | t(Alpha) | Beta(Mkt) | R-squared
----------------------------------------------------------------------------------------------------
CAPM | Low | 1.39% | 2.08 | 0.94 | 89.87%
CAPM | Medium | 0.88% | 1.84 | 0.89 | 93.86%
CAPM | High | -0.74% | -1.41 | 1.09 | 95.04%
----------------------------------------------------------------------------------------------------
FF3 | Low | 0.57% | 0.92 | 0.96 | 91.39%
FF3 | Medium | 0.14% | 0.38 | 0.94 | 96.57%
FF3 | High | 0.04% | 0.08 | 1.07 | 96.15%