import pandas as pd
import numpy as np
from tqdm import tqdm
Enhanced Portfolio Optimization - Replication
Code
= pd.read_csv("../data/49_Industry_Portfolios_Daily.csv", skiprows=9, nrows=25400)
df = df.rename(columns={"Unnamed: 0": "Date"}).dropna()
df "Date"] = pd.to_datetime(df["Date"], format="%Y%m%d") df[
Code
= pd.readdf = pd.read_csv("../data/F-F_Research_Data_Factors_daily.csv", skiprows=4, nrows=25400)
ff = ff.rename(columns={"Unnamed: 0": "Date"}).dropna()
ff "Date"] = pd.to_datetime(ff["Date"], format="%Y%m%d") ff[
Code
-99.99, np.nan, inplace=True)
df.replace(-99.99, np.nan, inplace=True)
ff.replace("Date", inplace=True)
df.set_index("Date", inplace=True)
ff.set_index(= df / 100
df = ff / 100 ff
def calSharpeRatio(r, multiplier=12):
"""
Args:
r: return series
"""
= r.mean() * multiplier / (np.std(r) * np.sqrt(multiplier))
sharpe return sharpe
1 Data Preparation
- Narrow the backtesting period to 1942 to 2018 to keep consistent with the paper.
- Turn daily returns into monthly returns.
= pd.DatetimeIndex(np.arange("1942-01", "2019-01", dtype="datetime64")).to_period("M") backtestingPeriods
= df.copy(deep=True)
dailyRet = ff.copy(deep=True)["RF"]
rfRet
def _dailyToMonthlyRetHelper(x):
return (1+x).prod(min_count=1) - 1
= dailyRet.groupby(pd.Grouper(
monthlyRet ="Date", freq="1M")).apply(_dailyToMonthlyRetHelper)
level= monthlyRet.index.to_period("M")
monthlyRet.index
= rfRet.groupby(pd.Grouper(
rfMonthlyRet ="Date", freq="1M")).apply(_dailyToMonthlyRetHelper)
level= rfMonthlyRet.index.to_period("M")
rfMonthlyRet.index
= dailyRet.subtract(rfRet.values, axis=0)
dailyExcessRet = dailyExcessRet.groupby(
monthlyExcessRet ="1M")).apply(_dailyToMonthlyRetHelper)
pd.Grouper(freq= monthlyExcessRet.index.to_period("M") monthlyExcessRet.index
2 1/N portfolio
# equally weighted
= monthlyExcessRet.mean(axis=1, skipna=True) portfolioExcessRet
= portfolioExcessRet.index.isin(backtestingPeriods)
_dateMask calSharpeRatio(portfolioExcessRet[_dateMask])
0.5902477272794807
3 MVO and EPO Implementation
Here is the implementation of MVO and EPO. EPO adds the shrinkage in its covariance matrix estimation. To extend the testing scope, I also added a long-only constraint which utilizes the cvxpy
package.
def calStandardMVO(s, V, gamma, **kwargs):
"""
Args:
s: signal
V: covariance matrix
gamma: risk aversion (usually between 1 to 10)
Return:
portfolio allocations
"""
= len(s)
N = np.linalg.inv(V)
V_inv # gamma = np.ones(N)@V_inv@s
= 1 / gamma * V_inv@s
x return x
def calEPO(s, V, w, gamma, long_only=False):
= np.sqrt(np.diag(V))
sigma = np.power(np.diag(sigma),2)
sigmaM = (1-w) * V + w * sigmaM
shrunkV if not long_only:
= 1/gamma * np.linalg.inv(shrunkV) @ s
x return x
else:
import cvxpy as cp
= len(s)
N = cp.Variable(N)
x =[x >= 0, np.ones(N).T@x == 1]
constraints= cp.Maximize(s.T@x - gamma/2*cp.quad_form(x, shrunkV))
objecive =cp.Problem(objecive, constraints)
prob
prob.solve()return x.value
4 Signal Implementation
The cross-sectional momentum signal is defined as follows. It looks at the past 12-month returns of each assets, and assign positive weights to assets that exceed the average.
\[ s_t^i=\operatorname{XSMOM}_t^i:=c_t\left(r_{t-12, t}^i-\frac{1}{n} \sum_{j=1, \ldots, n} r_{t-12, t}^j\right) \]
The weights are then normalized to sum to 1 respectively for long and short positions, scaled by \(c_t\)
\[ \sum_i s_t^i 1_{\left\{s_t^i>0\right\}}=\sum_i\left|s_t^i\right| 1_{\left\{s_t^i<0\right\}}=1 \]
def signalXSMOM(sectorRet):
= (1 + sectorRet).prod(axis=0) - 1
sectorCumRet # avg returns across sectors
= sectorCumRet.mean()
avg = sectorCumRet - avg
s = np.sum(s[s >= 0])
posSum = np.sum(s[s < 0])
negSum = np.where(s >= 0, s / posSum, -s/negSum)
s return s
5 Backtesting
To make the code concise, I will write a backtesting function that takes in an allocation function as an argument.
def backtest(allocation_func, **kwargs):
= list()
portfolioRet for valuationDate in backtestingPeriods:
# risk model based on past 5 years
= monthlyExcessRet.loc[valuationDate - 60: valuationDate - 1]
monthlySlice = monthlySlice.dropna(axis=1)
monthlySlice = monthlySlice.cov()
V = V.columns
investables
# signal generated based on past 12 months
= signalXSMOM(monthlySlice.loc[valuationDate - 12: valuationDate - 1])
s
# portfolio allocation
= allocation_func(s, V, gamma=len(investables)/0.4, **kwargs)
x = pd.Series(index=investables, data=x)
x
# append the return
= monthlyExcessRet.loc[valuationDate]
thisMonthRet = (thisMonthRet * x).dropna().sum()
thisMonthPortfolioRet
portfolioRet.append(thisMonthPortfolioRet)return calSharpeRatio(pd.Series(portfolioRet))
= {
allocation_funcs "standard MVO": (calStandardMVO, {}),
"EPO": (calEPO, {'w' : 0.5, 'long_only': False}),
"EPO long only": (calEPO, {'w' : 0.5, 'long_only': True})
}
for name, func in allocation_funcs.items():
print(name, backtest(func[0], **func[1]))
standard MVO 0.16825747296896637
EPO 0.7977512637644565
EPO long only 0.8336651855961855