Skip to content

Commit 0639ea9

Browse files
Added Fama-French 3-factor and 5-factor expected return models
1 parent c524c6e commit 0639ea9

4 files changed

Lines changed: 417 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ A far more comprehensive version of this can be found on [ReadTheDocs](https://p
211211
- Capital Asset Pricing Model (CAPM):
212212
- a simple model to predict returns based on the beta to the market
213213
- this is used all over finance!
214+
- Fama-French 3-factor model:
215+
- estimates expected returns using exposures to market, size, and value factors
216+
- Fama-French 5-factor model:
217+
- extends FF3 with profitability and investment factors
214218

215219
### Risk models (covariance)
216220

‎docs/ExpectedReturns.rst‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Expected Returns
66

77
Mean-variance optimization requires knowledge of the expected returns. In practice,
88
these are rather difficult to know with any certainty. Thus the best we can do is to
9-
come up with estimates, for example by extrapolating historical data, This is the
9+
come up with estimates, for example by extrapolating historical data or factor models. This is the
1010
main flaw in mean-variance optimization – the optimization procedure is sound, and provides
1111
strong mathematical guarantees, *given the correct inputs*. This is one of the reasons
1212
why I have emphasised modularity: users should be able to come up with their own
@@ -51,6 +51,20 @@ superior models and feed them into the optimizer.
5151
.. autofunction:: returns_from_prices
5252

5353
.. autofunction:: prices_from_returns
54+
55+
.. autofunction:: ff_return
56+
57+
Estimates expected returns using the Fama-French factor models.
58+
59+
Set ``model="ff3"`` to use the three-factor specification with
60+
market (Mkt-RF), size (SMB), and value (HML) factors.
61+
62+
Set ``model="ff5"`` to use the five-factor specification, which
63+
additionally includes profitability (RMW) and investment (CMA)
64+
factors.
65+
66+
Factor data must be supplied via the ``factor_data`` argument and
67+
contain the required factor return columns indexed by date.
5468

5569

5670
.. References

‎pypfopt/expected_returns.py‎

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- mean historical return
1717
- exponentially weighted mean historical return
1818
- CAPM estimate of returns
19+
- Fama-French 3-factor and 5-factor estimates of returns
1920
2021
Additionally, we provide utility functions to convert from returns to prices and vice-versa.
2122
"""
@@ -105,6 +106,8 @@ def return_model(prices, method="mean_historical_return", **kwargs):
105106
- ``mean_historical_return``
106107
- ``ema_historical_return``
107108
- ``capm_return``
109+
- ``ff3_return``
110+
- ``ff5_return``
108111
109112
Raises
110113
------
@@ -114,14 +117,18 @@ def return_model(prices, method="mean_historical_return", **kwargs):
114117
Returns
115118
-------
116119
pd.DataFrame
117-
annualised sample covariance matrix
120+
annualised expected return estimate for each asset
118121
"""
119122
if method == "mean_historical_return":
120123
return mean_historical_return(prices, **kwargs)
121124
elif method == "ema_historical_return":
122125
return ema_historical_return(prices, **kwargs)
123126
elif method == "capm_return":
124127
return capm_return(prices, **kwargs)
128+
elif method == "ff3_return":
129+
return ff_return(prices, model="ff3", **kwargs)
130+
elif method == "ff5_return":
131+
return ff_return(prices, model="ff5", **kwargs)
125132
else:
126133
raise NotImplementedError("Return model {} not implemented".format(method))
127134

@@ -313,3 +320,119 @@ def capm_return(
313320

314321
# CAPM formula
315322
return risk_free_rate + betas * (mkt_mean_ret - risk_free_rate)
323+
324+
325+
def ff_return(
326+
prices,
327+
factor_data,
328+
returns_data=False,
329+
model="ff3",
330+
compounding=True,
331+
frequency=252,
332+
log_returns=False,
333+
):
334+
"""
335+
Compute a return estimate using the Fama-French factor model.
336+
337+
Parameters
338+
----------
339+
prices : pd.DataFrame
340+
adjusted closing prices of the assets.
341+
factor_data : pd.DataFrame
342+
factor returns indexed by date.
343+
344+
Required columns for ff3:
345+
- RF
346+
- Mkt-RF
347+
- SMB
348+
- HML
349+
350+
Additional required columns for ff5:
351+
- RMW
352+
- CMA
353+
354+
returns_data : bool, optional
355+
if true, prices is interpreted as returns.
356+
model : str, optional
357+
one of {"ff3", "ff5"}.
358+
compounding : bool, optional
359+
use geometric annualisation if True.
360+
frequency : int, optional
361+
periods per year.
362+
log_returns : bool, optional
363+
whether to compute log returns.
364+
365+
Returns
366+
-------
367+
pd.Series
368+
annualised expected returns.
369+
"""
370+
371+
if not isinstance(prices, pd.DataFrame):
372+
warnings.warn("prices are not in a dataframe", RuntimeWarning)
373+
prices = pd.DataFrame(prices)
374+
375+
if not isinstance(factor_data, pd.DataFrame):
376+
warnings.warn("factor_data is not in a dataframe", RuntimeWarning)
377+
factor_data = pd.DataFrame(factor_data)
378+
379+
if model not in {"ff3", "ff5"}:
380+
raise ValueError("model must be either 'ff3' or 'ff5'")
381+
382+
if returns_data:
383+
returns = prices.copy()
384+
else:
385+
returns = returns_from_prices(prices, log_returns)
386+
387+
_check_returns(returns)
388+
389+
required = ["RF", "Mkt-RF", "SMB", "HML"]
390+
if model == "ff5":
391+
required.extend(["RMW", "CMA"])
392+
393+
missing = [c for c in required if c not in factor_data.columns]
394+
if missing:
395+
raise ValueError(f"factor_data missing required columns: {missing}")
396+
397+
common_index = returns.index.intersection(factor_data.index)
398+
if len(common_index) == 0:
399+
raise ValueError("No overlapping dates between asset returns and factor data")
400+
401+
returns = returns.loc[common_index]
402+
factors = factor_data.loc[common_index, required].copy()
403+
404+
data = returns.join(factors, how="inner").dropna()
405+
if data.empty:
406+
raise ValueError("No valid rows after aligning returns and factor data")
407+
408+
returns = data[returns.columns]
409+
factors = data[required]
410+
411+
excess_returns = returns.sub(factors["RF"], axis=0)
412+
413+
factor_cols = ["Mkt-RF", "SMB", "HML"]
414+
if model == "ff5":
415+
factor_cols.extend(["RMW", "CMA"])
416+
417+
X = np.column_stack([np.ones(len(factors)), factors[factor_cols].to_numpy()])
418+
factor_means = factors[factor_cols].mean().to_numpy()
419+
420+
expected_returns = {}
421+
rf_mean = factors["RF"].mean()
422+
423+
for asset in excess_returns.columns:
424+
y = excess_returns[asset].to_numpy()
425+
beta = np.linalg.lstsq(X, y, rcond=None)[0]
426+
alpha = beta[0]
427+
factor_loadings = beta[1:]
428+
429+
expected_period_return = rf_mean + alpha + factor_loadings @ factor_means
430+
431+
if compounding:
432+
expected_return = (1 + expected_period_return) ** frequency - 1
433+
else:
434+
expected_return = expected_period_return * frequency
435+
436+
expected_returns[asset] = expected_return
437+
438+
return pd.Series(expected_returns, dtype="float64")

0 commit comments

Comments
 (0)