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
2021Additionally, 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