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:

\[R_{p,t} - R_{f,t} = \alpha_p + \beta_{MKT}(R_{m,t} - R_{f,t}) + \beta_{SMB} \cdot SMB_t + \beta_{HML} \cdot HML_t + \epsilon_{p,t}\]

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#

  1. Understand the Fama-French 3-factor model

  2. Run 3-factor regressions using the finm package

  3. Compare results with CAPM regressions

  4. Interpret factor loadings (betas on SMB and HML)

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

  1. R-squared Improvement: The FF3 model typically explains more variance (higher R-squared) than CAPM by adding size and value factors.

  2. Factor Loadings: Investment portfolios may have systematic exposures to size and value that partially explain their returns.

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