factors_r = ["SP500", "DTWEXAFEGS"] # "SP500" does not contain dividends; note: "DTWEXM" discontinued as of Jan 2020
factors_d = ["DGS10", "BAMLH0A0HYM2"]Random weights
Need to generate uniformly distributed weights \(\mathbf{w}=(w_{1},w_{2},\ldots,w_{N})\) such that \(\sum_{i=1}^{N}w_{i}=1\) and \(w_{i}\geq0\):
Approach 1: tempting to use \(w_{i}=\frac{u_{i}}{\sum_{j=1}^{N}u_{j}}\) where \(u_{i}\sim U(0,1)\) but the distribution of \(\mathbf{w}\) is not uniform
Approach 2: instead, generate \(\text{Exp}(1)\) and then normalize
Can also scale random weights by \(M\), e.g. if sum of weights must be 10% then multiply weights by 10%.
def rand_weights1(n_sim, n_assets):
rand_unif = np.random.uniform(size = (n_sim, n_assets))
rand_unif_sum = np.sum(rand_unif, axis = 1, keepdims = True)
result = rand_unif / rand_unif_sum
return resultn_assets = 3
n_sim = 10000approach1 = rand_weights1(n_sim, n_assets)
The weights cluster toward the center of the simplex, i.e. the distribution is not uniform.
Approach 2(a): uniform sample from the simplex (http://mathoverflow.net/a/76258) and then normalize
- If \(u\sim U(0,1)\) then \(-\ln(u)\) is an \(\text{Exp}(1)\) distribution
This is also known as generating a random vector from the symmetric Dirichlet distribution.
def rand_weights2a(n_sim, n_assets, lmbda):
# inverse transform sampling: https://en.wikipedia.org/wiki/Inverse_transform_sampling
rand_exp = -np.log(1 - np.random.uniform(size = (n_sim, n_assets))) / lmbda
rand_exp_sum = np.sum(rand_exp, axis = 1, keepdims = True)
result = rand_exp / rand_exp_sum
return resultlmbda = 1approach2a = rand_weights2a(n_sim, n_assets, lmbda)
Approach 2(b): directly generate \(\text{Exp}(1)\) and then normalize
def rand_weights2b(n_sim, n_assets):
rand_exp = np.random.exponential(size = (n_sim, n_assets))
rand_exp_sum = np.sum(rand_exp, axis = 1, keepdims = True)
result = rand_exp / rand_exp_sum
return resultapproach2b = rand_weights2b(n_sim, n_assets)
Random turnover
How to generate random weights between lower bound \(a\) and upper bound \(b\) that sum to zero?
Approach 1: tempting to multiply random weights by \(M\) and then subtract \(\frac{M}{N}\) but the distribution is not between \(a\) and \(b\)
Approach 2: instead, use an iterative approach for random turnover:
- Generate \(N-1\) uniformly distributed weights between \(a\) and \(b\)
- For \(u_{N}\) compute sum of values and subtract from \(M\)
- If \(u_{N}\) is between \(a\) and \(b\), then keep; otherwise, discard
Then add random turnover to previous period’s random weights.
def rand_turnover1(n_sim, n_assets, lower, upper, target):
rng = upper - lower
result = rand_weights2b(n_sim, n_assets) * rng
result = result - rng / n_assets + target / n_assets
# or draw uniforms between bounds and then subtract the row means
# result = np.random.uniform(lower, upper, size = (n_sim, n_assets))
# result = result - result.mean(axis = 1, keepdims = True) + target / n_assets
return resultlower = -0.05
upper = 0.05
target = 0approach1 = rand_turnover1(n_sim, n_assets, lower, upper, target)
def rand_iterative(n_assets, lower, upper, target):
result = np.random.uniform(low = lower, high = upper, size = n_assets - 1)
temp = target - sum(result)
while not ((temp <= upper) and (temp >= lower)):
result = np.random.uniform(low = lower, high = upper, size = n_assets - 1)
temp = target - sum(result)
result = np.append(result, temp)
return resultdef rand_turnover2(n_sim, n_assets, lower, upper, target):
result_ls = []
for i in range(n_sim):
result_sim = rand_iterative(n_assets, lower, upper, target)
result_ls.append(result_sim)
result = pd.DataFrame(result_ls)
return resultapproach2 = rand_turnover2(n_sim, n_assets, lower, upper, target)
Mean-variance
import cvxpy as cpdef geometric_mean(x, scale):
result = np.prod(1 + x) ** (scale / len(x)) - 1
return result- https://www.adrian.idv.hk/2021-06-22-kkt/
- https://or.stackexchange.com/a/3738
- https://bookdown.org/compfinezbook/introFinRbook/Portfolio-Theory-with-Matrix-Algebra.html#algorithm-for-computing-efficient-frontier
- https://palomar.home.ece.ust.hk/MAFS6010R_lectures/slides_robust_portfolio.html
The derivations below incorporate the budget constraint \(\mathbf{w}^{T}e=1\) and, where applicable, a binding target constraint. The optimizers additionally impose the long-only constraint \(\mathbf{w}\geq0\), which has no closed-form solution and instead requires the Karush-Kuhn-Tucker (KKT) conditions; when the long-only constraint does not bind, the analytic and numerical solutions coincide.
tickers = ["BAICX"] # fund inception date is "2011-11-28"returns_x_df = returns_df.dropna()[factors] # extended history (since fund inception)
mu = returns_x_df.apply(geometric_mean, axis = 0, scale = scale["periods"])
sigma = np.cov(overlap_x_df.T, ddof = 1) * scale["periods"] * scale["overlap"]Maximize mean
\[ \begin{aligned} \begin{array}{rrcl} \displaystyle\min&-\mathbf{w}^{T}\mu\\ \textrm{s.t.}&\mathbf{w}^{T}e&=&1\\ &\mathbf{w}^T\Sigma\mathbf{w}&\leq&\sigma^{2}\\ \end{array} \end{aligned} \]
Since the objective is linear, a Lagrangian with only the budget constraint \(\mathbf{w}^{T}e=1\) has no solution: the first-order condition \(-\mu-\lambda_{1}e=0\) contains no \(\mathbf{w}\). Instead, the volatility constraint must bind at the optimum, i.e. \(\mathbf{w}^{T}\Sigma\mathbf{w}=\sigma^{2}\), so the solution lies on the efficient frontier. Maximizing the mean subject to a volatility target selects the same frontier portfolio as minimizing variance subject to the corresponding return target \(M\) (see the next section). A closed form requires solving the stationarity condition \(\mu=2\lambda_{2}\Sigma\mathbf{w}+\lambda_{1}e\) together with both constraints, i.e. a quadratic equation in the multipliers, so solve the problem numerically instead.
def max_mean_optim(mu, sigma, target):
params = cp.Variable(len(mu))
obj = cp.Maximize(params.T @ mu)
cons = [cp.sum(params) == 1, params >= 0,
cp.quad_form(params, sigma) <= target ** 2]
prob = cp.Problem(obj, cons)
prob.solve()
return params.valuetarget = 0.06params1 = max_mean_optim(mu, sigma, target)
params1array([3.87192932e-01, 1.00960889e-08, 6.12807047e-01, 1.09093173e-08])
np.dot(mu, params1)np.float64(0.06979644307922897)
np.sqrt(np.dot(params1, np.dot(sigma, params1)))np.float64(0.05999999994282559)
Minimize variance
\[ \begin{aligned} \begin{array}{rrcl} \displaystyle\min&\frac{1}{2}\mathbf{w}^T\Sigma\mathbf{w}\\ \textrm{s.t.}&\mathbf{w}^{T}e&=&1\\ &\mu^{T}\mathbf{w}&\geq&M\\ \end{array} \end{aligned} \]
Assume the return constraint binds at the optimum, i.e. the expected return of the unconstrained minimum variance portfolio is below \(M\), and treat it as an equality (otherwise omit the \(\lambda_{2}\) terms). To incorporate these conditions into one equation, introduce new variables \(\lambda_{i}\) that are the Lagrange multipliers and define a new function \(\mathcal{L}\) as follows:
\[ \begin{aligned} \mathcal{L}(\mathbf{w},\lambda)&=\frac{1}{2}\mathbf{w}^{T}\Sigma\mathbf{w}-\lambda_{1}(\mathbf{w}^{T}e-1)-\lambda_{2}(\mu^{T}\mathbf{w}-M) \end{aligned} \]
Then, to minimize this function, take derivatives with respect to \(w\) and Lagrange multipliers \(\lambda_{i}\):
\[ \begin{aligned} \frac{\partial\mathcal{L}(\mathbf{w},\lambda)}{\partial w}&=\Sigma\mathbf{w}-\lambda_{1}e-\lambda_{2}\mu=0\\ \frac{\partial\mathcal{L}(\mathbf{w},\lambda)}{\partial \lambda_{1}}&=\mathbf{w}^{T}e-1=0\\ \frac{\partial\mathcal{L}(\mathbf{w},\lambda)}{\partial \lambda_{2}}&=\mu^{T}\mathbf{w}-M=0 \end{aligned} \]
Simplify the equations above in matrix form and solve for the weights \(\mathbf{w}\) and Lagrange multipliers \(\lambda_{i}\):
\[ \begin{aligned} \begin{bmatrix} \Sigma & e & \mu \\ e^{T} & 0 & 0 \\ \mu^{T} & 0 & 0 \end{bmatrix} \begin{bmatrix} \mathbf{w} \\ -\lambda_{1} \\ -\lambda_{2} \end{bmatrix} &= \begin{bmatrix} 0 \\ 1 \\ M \end{bmatrix} \\ \begin{bmatrix} \mathbf{w} \\ -\lambda_{1} \\ -\lambda_{2} \end{bmatrix} &= \begin{bmatrix} \Sigma & e & \mu \\ e^{T} & 0 & 0 \\ \mu^{T} & 0 & 0 \end{bmatrix}^{-1} \begin{bmatrix} 0 \\ 1 \\ M \end{bmatrix} \end{aligned} \]
def min_var_optim(mu, sigma, target):
params = cp.Variable(len(mu))
obj = cp.Minimize(cp.quad_form(params, sigma))
cons = [cp.sum(params) == 1, params >= 0,
params.T @ mu >= target]
prob = cp.Problem(obj, cons)
prob.solve()
return params.valuetarget = 0.03params2 = min_var_optim(mu, sigma, target)
params2array([ 1.62859486e-01, 4.17694220e-03, 8.32963572e-01, -3.23749596e-20])
np.dot(mu, params2)np.float64(0.029999999999999995)
np.sqrt(np.dot(params2, np.dot(sigma, params2))) np.float64(0.026750854320165024)
Maximize utility
\[ \begin{aligned} \begin{array}{rrcl} \displaystyle\min&\frac{1}{2}\delta(\mathbf{w}^{T}\Sigma\mathbf{w})-\mu^{T}\mathbf{w}\\ \textrm{s.t.}&\mathbf{w}^{T}e&=&1\\ \end{array} \end{aligned} \]
To incorporate these conditions into one equation, introduce new variables \(\lambda_{i}\) that are the Lagrange multipliers and define a new function \(\mathcal{L}\) as follows:
\[ \begin{aligned} \mathcal{L}(\mathbf{w},\lambda)&=\frac{1}{2}\delta(\mathbf{w}^{T}\Sigma\mathbf{w})-\mu^{T}\mathbf{w}-\lambda_{1}(\mathbf{w}^{T}e-1) \end{aligned} \]
Then, to minimize this function, take derivatives with respect to \(w\) and Lagrange multipliers \(\lambda_{i}\):
\[ \begin{aligned} \frac{\partial\mathcal{L}(\mathbf{w},\lambda)}{\partial w}&=\delta\Sigma\mathbf{w}-\mu-\lambda_{1}e=0\\ \frac{\partial\mathcal{L}(\mathbf{w},\lambda)}{\partial \lambda_{1}}&=\mathbf{w}^{T}e-1=0 \end{aligned} \]
Simplify the equations above in matrix form and solve for the weights \(\mathbf{w}\) and Lagrange multiplier \(\lambda_{1}\):
\[ \begin{aligned} \begin{bmatrix} \delta\Sigma & e \\ e^{T} & 0 \end{bmatrix} \begin{bmatrix} \mathbf{w} \\ -\lambda_{1} \end{bmatrix} &= \begin{bmatrix} \mu \\ 1 \end{bmatrix} \\ \begin{bmatrix} \mathbf{w} \\ -\lambda_{1} \end{bmatrix} &= \begin{bmatrix} \delta\Sigma & e \\ e^{T} & 0 \end{bmatrix}^{-1} \begin{bmatrix} \mu \\ 1 \end{bmatrix} \end{aligned} \]
def max_utility_optim(mu, sigma, target):
params = cp.Variable(len(mu))
obj = cp.Minimize(0.5 * target * cp.quad_form(params, sigma) - params.T @ mu)
cons = [cp.sum(params) == 1, params >= 0]
prob = cp.Problem(obj, cons)
prob.solve()
return params.valueir = 0.5
target = ir / 0.06 # implied risk aversion, i.e. ratio / risk (see Black-Litterman model)params3 = max_utility_optim(mu, sigma, target)
params3array([9.20958944e-01, 1.76261832e-23, 7.90410560e-02, 2.94198952e-24])
np.dot(mu, params3)np.float64(0.1642679280376907)
np.sqrt(np.matmul(np.transpose(params3), np.matmul(sigma, params3)))np.float64(0.14048775820249265)
Minimize residual sum of squares
\[ \begin{aligned} \begin{array}{rrcl} \displaystyle\min&\frac{1}{2}\mathbf{w}^{T}X^{T}X\mathbf{w}-(X^{T}y)^{T}\mathbf{w}\\ \textrm{s.t.}&\mathbf{w}^{T}e&=&1\\ \end{array} \end{aligned} \]
To incorporate these conditions into one equation, introduce new variables \(\lambda_{i}\) that are the Lagrange multipliers and define a new function \(\mathcal{L}\) as follows:
\[ \begin{aligned} \mathcal{L}(\mathbf{w},\lambda)&=\frac{1}{2}\mathbf{w}^{T}X^{T}X\mathbf{w}-(X^{T}y)^{T}\mathbf{w}-\lambda_{1}(\mathbf{w}^{T}e-1) \end{aligned} \]
Then, to minimize this function, take derivatives with respect to \(w\) and Lagrange multipliers \(\lambda_{i}\):
\[ \begin{aligned} \frac{\partial\mathcal{L}(\mathbf{w},\lambda)}{\partial w}&=X^{T}X\mathbf{w}-X^{T}y-\lambda_{1}e=0\\ \frac{\partial\mathcal{L}(\mathbf{w},\lambda)}{\partial \lambda_{1}}&=\mathbf{w}^{T}e-1=0 \end{aligned} \]
Simplify the equations above in matrix form and solve for the weights \(\mathbf{w}\) and Lagrange multiplier \(\lambda_{1}\):
\[ \begin{aligned} \begin{bmatrix} X^{T}X & e \\ e^{T} & 0 \end{bmatrix} \begin{bmatrix} \mathbf{w} \\ -\lambda_{1} \end{bmatrix} &= \begin{bmatrix} X^{T}y \\ 1 \end{bmatrix} \\ \begin{bmatrix} \mathbf{w} \\ -\lambda_{1} \end{bmatrix} &= \begin{bmatrix} X^{T}X & e \\ e^{T} & 0 \end{bmatrix}^{-1} \begin{bmatrix} X^{T}y \\ 1 \end{bmatrix} \end{aligned} \]
def min_rss_optim1(mu, sigma):
params = cp.Variable(len(mu))
obj = cp.Minimize(0.5 * cp.quad_form(params, sigma) - params.T @ mu)
cons = [cp.sum(params) == 1, params >= 0]
prob = cp.Problem(obj, cons)
prob.solve()
return params.valueparams4 = min_rss_optim1(np.dot(overlap_x_df.T.values, overlap_y_df.values),
np.dot(overlap_x_df.T.values, overlap_x_df.values))
params4array([ 2.89897575e-01, 1.18230106e-23, 7.10102425e-01, -6.84673997e-24])
np.dot(mu, params4)np.float64(0.05257609456378635)
np.sqrt(np.matmul(np.transpose(params4), np.matmul(sigma, params4)))np.float64(0.045466442920312856)
def min_rss_optim2(x, y):
params = cp.Variable(x.shape[1])
obj = cp.Minimize(cp.sum_squares(y - x @ params))
cons = [cp.sum(params) == 1, params >= 0]
prob = cp.Problem(obj, cons)
prob.solve()
return params.valueparams5 = min_rss_optim2(overlap_x_df.values, overlap_y_df.iloc[:, 0].values)
params5array([ 2.89897575e-01, -1.08041687e-21, 7.10102425e-01, -8.51906280e-22])
np.dot(mu, params5)np.float64(0.052576094563786334)
np.sqrt(np.matmul(np.transpose(params5), np.matmul(sigma, params5)))np.float64(0.04546644292031284)
pd.DataFrame({
"max_mean": params1 * 100,
"min_var": params2 * 100,
"max_utility": params3 * 100,
"min_rss1": params4 * 100,
"min_rss2": params5 * 100
}).round(2) max_mean min_var max_utility min_rss1 min_rss2
0 38.72 16.29 92.1 28.99 28.99
1 0.00 0.42 0.0 0.00 -0.00
2 61.28 83.30 7.9 71.01 71.01
3 0.00 -0.00 0.0 -0.00 -0.00