import pandas as pd
import numpy as np
from tqdm import tqdmEnhanced Portfolio Optimization - Replication
Code
df = 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")Code
ff = 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")Code
df.replace(-99.99, np.nan, inplace=True)
ff.replace(-99.99, np.nan, inplace=True)
df.set_index("Date", inplace=True)
ff.set_index("Date", inplace=True)
df = df / 100
ff = ff / 100def calSharpeRatio(r, multiplier=12):
"""
Args:
r: return series
"""
sharpe = r.mean() * multiplier / (np.std(r) * np.sqrt(multiplier))
return sharpe1 Data Preparation
- Narrow the backtesting period to 1942 to 2018 to keep consistent with the paper.
- Turn daily returns into monthly returns.
backtestingPeriods = pd.DatetimeIndex(np.arange("1942-01", "2019-01", dtype="datetime64")).to_period("M")dailyRet = df.copy(deep=True)
rfRet = ff.copy(deep=True)["RF"]
def _dailyToMonthlyRetHelper(x):
return (1+x).prod(min_count=1) - 1
monthlyRet = dailyRet.groupby(pd.Grouper(
level="Date", freq="1M")).apply(_dailyToMonthlyRetHelper)
monthlyRet.index = monthlyRet.index.to_period("M")
rfMonthlyRet = rfRet.groupby(pd.Grouper(
level="Date", freq="1M")).apply(_dailyToMonthlyRetHelper)
rfMonthlyRet.index = rfMonthlyRet.index.to_period("M")
dailyExcessRet = dailyRet.subtract(rfRet.values, axis=0)
monthlyExcessRet = dailyExcessRet.groupby(
pd.Grouper(freq="1M")).apply(_dailyToMonthlyRetHelper)
monthlyExcessRet.index = monthlyExcessRet.index.to_period("M")2 1/N portfolio
# equally weighted
portfolioExcessRet = monthlyExcessRet.mean(axis=1, skipna=True)_dateMask = portfolioExcessRet.index.isin(backtestingPeriods)
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
"""
N = len(s)
V_inv = np.linalg.inv(V)
# gamma = np.ones(N)@V_inv@s
x = 1 / gamma * V_inv@s
return x
def calEPO(s, V, w, gamma, long_only=False):
sigma = np.sqrt(np.diag(V))
sigmaM = np.power(np.diag(sigma),2)
shrunkV = (1-w) * V + w * sigmaM
if not long_only:
x = 1/gamma * np.linalg.inv(shrunkV) @ s
return x
else:
import cvxpy as cp
N = len(s)
x = cp.Variable(N)
constraints=[x >= 0, np.ones(N).T@x == 1]
objecive = cp.Maximize(s.T@x - gamma/2*cp.quad_form(x, shrunkV))
prob=cp.Problem(objecive, constraints)
prob.solve()
return x.value4 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):
sectorCumRet = (1 + sectorRet).prod(axis=0) - 1
# avg returns across sectors
avg = sectorCumRet.mean()
s = sectorCumRet - avg
posSum = np.sum(s[s >= 0])
negSum = np.sum(s[s < 0])
s = np.where(s >= 0, s / posSum, -s/negSum)
return s5 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):
portfolioRet = list()
for valuationDate in backtestingPeriods:
# risk model based on past 5 years
monthlySlice = monthlyExcessRet.loc[valuationDate - 60: valuationDate - 1]
monthlySlice = monthlySlice.dropna(axis=1)
V = monthlySlice.cov()
investables = V.columns
# signal generated based on past 12 months
s = signalXSMOM(monthlySlice.loc[valuationDate - 12: valuationDate - 1])
# portfolio allocation
x = allocation_func(s, V, gamma=len(investables)/0.4, **kwargs)
x = pd.Series(index=investables, data=x)
# append the return
thisMonthRet = monthlyExcessRet.loc[valuationDate]
thisMonthPortfolioRet = (thisMonthRet * x).dropna().sum()
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