Note

Download this Jupyter notebook and all data (unzip next to the ipynb file!). You will need a Gurobi license to run this notebook, please follow the license instructions.


Limiting Turnover#

The standard mean-variance (Markowitz) portfolio selection model determines an optimal investment portfolio that balances risk and expected return. In this notebook, we maximize the portfolio’s expected return while constraining the admissible variance (risk) to a given maximum level. Please refer to the annotated list of references for more background information on portfolio optimization.

In this notebook, we rebalance an existing portfolio and add a turnover constraint. Turnover constraints restrict the transaction volume and can implicitly affect the number of transactions. They limit the distance between the initial portfolio and the rebalanced one and, as such, favor stable investment strategies, which align with the objectives of institutional investors. Additionally, turnover constraints often mitigate what is sometimes referred to as the error-maximizing property of the classical mean-variance portfolio. Turnover constraints can be used as substitutes or complements to transaction cost constraints. They are also somewhat related to the tracking concept in portfolio optimization.

[2]:
import gurobipy as gp
import pandas as pd
import numpy as np
import scipy.linalg as la
import random

Input Data#

The following input data is used within the model:

  • \(S\): set of stocks

  • \(\mu\): vector of expected returns

  • \(\Sigma\): PSD variance-covariance matrix

    • \(\sigma_{ij}\) covariance between returns of assets \(i\) and \(j\)

    • \(\sigma_{ii}\) variance of return of asset \(i\)

[4]:
# Import some example data set
Sigma = pd.read_pickle("sigma.pkl")
mu = pd.read_pickle("mu.pkl")

Formulation#

The straightforward formulation of the turnover requirements results in a nonconvex optimization problem. However, the turnover constraint can be linearized and the optimization model can be equivalently reformulated as a convex quadratic optimization problem.

Incorporating a limit on turnover significantly expands the decision space; the number of decision variables is three times as large as the model without turnover constraints.

Model Parameters#

The following parameters are used within the model:

  • \(\bar\sigma^2\): maximal admissible variance for the portfolio return

  • \(\tau\): maximal turnover rate

  • \(x^0_i\): holdings of asset \(i\) in the initial portfolio. Usually, the initial portfolio is given by the previous trading period. In this notebook, we choose a simple initial portfolio by heuristically approximating the minimum variance portfolio on at most 20 assets.

[5]:
# Values for the model parameters:
V = 4.0  # Maximal admissible variance (sigma^2)
tau = 0.2  # Maximal turnover rate

# Initial portfolio
tmp = pd.Series(index=mu.index, data=la.solve(Sigma, np.ones(mu.shape), assume_a="pos"))
x0 = pd.Series(index=mu.index, data=np.zeros(mu.shape))
x0.loc[tmp.nlargest(20).index] = 1.0 / 20.0

Decision Variables#

We need three sets of decision variables:

  1. The proportion of capital invested in each stock in the rebalanced portfolio. The corresponding vector of positions is denoted by \(x\) with its component \(x_i\) denoting the proportion of capital invested in stock \(i\).

The other sets of variables distinguish between buys and sales:

  1. The proportions of each stock bought and included in the rebalanced portfolio. The corresponding vector of positions is denoted by \(x^+\) with its component \(x^+_i\) denoting the proportion of capital representing the buy of stock \(i\).

  2. The proportions of each stock sold from the initial portfolio. The corresponding vector of sales is denoted by \(x^-\) with its component \(x^-_i\) denoting the proportion of capital representing the sale of stock \(i\).

Variable Bounds#

Each position must be between 0 and 1; this prevents leverage and short-selling:

\[0\leq x_i\leq 1 \; , \; i \in S\]

The buy and sell proportions must be non-negative:

\[x_i^+, x_i^- \geq 0\; , \, i \in S\]
[6]:
%%capture
# Create an empty optimization model
m = gp.Model()

# Add variables: x[i] denotes the proportion invested in stock i
x = m.addMVar(len(mu), lb=0, ub=1, name="x")
# Add variables: x_plus[i] denotes the proportion of stock i bought
x_plus = m.addMVar(len(mu), lb=0, ub=1, name="x_plus")
# Add variables: x_minus[i] denotes the proportion of stock i sold
x_minus = m.addMVar(len(mu), lb=0, ub=1, name="x_minus")

Constraints#

The budget constraint ensures that all capital is invested:

\[\sum_{i \in S} x_i =1\]

The final proportion of capital invested in a stock in the rebalanced portfolio is obtained by taking into account the buys and sells of this stock as well as the holdings in the initial portfolio: \begin{equation*} x_i = x^0_i + x^+_i - x^-_i \; , \; i \in S \tag{1} \end{equation*}

The estimated risk must not exceed a prespecified maximal admissible level of variance \(\bar\sigma^2\):

\[x^\top \Sigma x \leq \bar\sigma^2\]
[7]:
%%capture
# Budget constraint: all investments sum up to 1
m.addConstr(x.sum() == 1, name="Budget_Constraint")

# Position rebalancing constraint, see formula (1) above
m.addConstr(x == x0.to_numpy() + x_plus - x_minus, name="Position_Balance")

# Upper bound on variance
m.addConstr(x @ Sigma.to_numpy() @ x <= V, name="Variance")

Turnover#

The turnover ratio is defined as the sum of the absolute values of the differences between the positions in the initial and rebalanced portfolios.

Denoting by \(x_i^0\) the position in asset \(i\) in the initial portfolio and by \(x_i\) the position in the rebalanced portfolio, the turnover ratio is defined as

\[\sum_{i \in S} |x_i - x^0_i|\]

and represents the absolute difference between the holdings in the two portfolios.

Accordingly, the turnover constraint

\[\sum_{i \in S} |x_i - x^0_i| \leq \tau\]

is nonconvex.

Linearization of Turnover Constraint#

We may assume that at least one of the two variables \(x^+_i\) or \(x^-_i\) is equal to zero for each asset \(i\), thus: \begin{equation*} \sum_{i \in S} |x_i - x^0_i| = \sum_{i \in S} |x^+_i - x^-_i| = \sum_{i \in S} x^+_i + \sum_{i \in S} x^-_i \end{equation*} Therefore, the turnover constraint can be replaced with the following linear constraint:

\begin{equation*} \sum_{i \in S} x^+_i + \sum_{i \in S} x^-_i \leq \tau\tag{2} \end{equation*}

[8]:
%%capture
# Linearized turnover constraint; see formula (2) above
m.addConstr(x_plus.sum() + x_minus.sum() <= tau, name="Turnover")

Objective Function#

The objective is to maximize the expected return of the portfolio:

\[\max \mu^\top x\]
[9]:
# Define objective: total expected return
m.setObjective(mu.to_numpy() @ x, gp.GRB.MAXIMIZE)

We now solve the optimization problem:

[10]:
m.optimize()
Gurobi Optimizer version 11.0.0 build v11.0.0rc2 (linux64 - "Ubuntu 22.04.4 LTS")

CPU model: Intel(R) Xeon(R) Platinum 8272CL CPU @ 2.60GHz, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 2 physical cores, 2 logical processors, using up to 2 threads

WLS license 2443533 - registered to Gurobi GmbH
Optimize a model with 464 rows, 1386 columns and 2772 nonzeros
Model fingerprint: 0xe5dfe62a
Model has 1 quadratic constraint
Coefficient statistics:
  Matrix range     [1e+00, 1e+00]
  QMatrix range    [3e-03, 1e+02]
  Objective range  [7e-02, 6e-01]
  Bounds range     [1e+00, 1e+00]
  RHS range        [5e-02, 1e+00]
  QRHS range       [4e+00, 4e+00]
Presolve time: 0.04s
Presolved: 927 rows, 1849 columns, 110188 nonzeros
Presolved model has 1 second-order cone constraint
Ordering time: 0.01s

Barrier statistics:
 AA' NZ     : 2.153e+05
 Factor NZ  : 2.423e+05 (roughly 3 MB of memory)
 Factor Ops : 9.108e+07 (less than 1 second per iteration)
 Threads    : 2

                  Objective                Residual
Iter       Primal          Dual         Primal    Dual     Compl     Time
   0   1.44527363e+01  1.72963108e-01  1.22e+02 5.99e-01  3.17e-02     0s
   1   1.65640068e+00  1.46901369e+00  1.19e+01 6.59e-07  3.44e-03     0s
   2   4.81434732e-01  5.07081086e-01  2.21e+00 1.15e-07  6.73e-04     0s
   3   2.90989668e-01  3.90349507e-01  6.40e-01 4.04e-08  2.17e-04     0s
   4   2.15881252e-01  3.16947809e-01  7.81e-02 1.15e-08  4.97e-05     0s
   5   2.11322330e-01  2.72467056e-01  2.43e-02 5.32e-09  2.28e-05     0s
   6   2.13792174e-01  2.53077825e-01  8.22e-03 2.42e-09  1.32e-05     0s
   7   2.34113264e-01  2.44677327e-01  4.56e-09 2.45e-10  3.26e-06     0s
   8   2.39097110e-01  2.42934779e-01  1.61e-09 5.14e-11  1.19e-06     0s
   9   2.42158328e-01  2.42383855e-01  9.37e-11 1.34e-13  6.97e-08     0s
  10   2.42337309e-01  2.42345446e-01  2.60e-12 3.89e-16  2.51e-09     0s
  11   2.42344331e-01  2.42344744e-01  1.31e-10 2.26e-15  1.28e-10     0s

Barrier solved model in 11 iterations and 0.29 seconds (0.44 work units)
Optimal objective 2.42344331e-01

Display basic solution data; for clarity we’ve rounded all solution quantities to five digits.

[11]:
print(f"Expected return before/after: {mu @ x0:.6f}/{m.ObjVal:.6f}")
print(f"Variance        before/after: {x0 @ Sigma @ x0:.6f}/{x.X @ Sigma @ x.X:.6f}")
print(f"Solution time:   {m.Runtime:.2f} seconds\n")

print(
    f"Number of positions: before {np.count_nonzero(x0[x0>1e-5])}, after {np.count_nonzero(x.X[x.X>1e-5])}"
)
print(f"Number of buys:      {np.count_nonzero(x_plus.X[x_plus.X>1e-5])}")
print(f"Number of sells:     {np.count_nonzero(x_minus.X[x_minus.X>1e-5])}")
print(f"Total turnover:      {np.abs(x.X - x0).sum():.6f}\n")

# Print all assets with either non-negligible position or transaction
df = pd.DataFrame(
    index=mu.index,
    data={
        "position": x.X,
        "transaction": x.X - x0,
        "turnover": np.abs(x.X - x0),
    },
).round(6)
df[(df["position"] > 1e-5) | (abs(df["transaction"]) > 1e-5)].sort_values(
    "turnover", ascending=False
)
Expected return before/after: 0.201269/0.242344
Variance        before/after: 3.844733/3.999995
Solution time:   0.30 seconds

Number of positions: before 20, after 23
Number of buys:      5
Number of sells:     2
Total turnover:      0.200000

[11]:
position transaction turnover
NVDA 0.070235 0.070235 0.070235
PEAK 0.000000 -0.050000 0.050000
TFC 0.000000 -0.050000 0.050000
TSLA 0.012008 0.012008 0.012008
LLY 0.006893 0.006893 0.006893
ENPH 0.006847 0.006847 0.006847
DXCM 0.004016 0.004016 0.004016
EG 0.050000 0.000000 0.000000
RF 0.050000 -0.000000 0.000000
WTW 0.050000 0.000000 0.000000
NVR 0.050000 0.000000 0.000000
CL 0.050000 0.000000 0.000000
DUK 0.050000 0.000000 0.000000
CMA 0.050000 -0.000000 0.000000
ED 0.050000 0.000000 0.000000
FRT 0.050000 -0.000000 0.000000
STT 0.050000 0.000000 0.000000
AMCR 0.050000 -0.000000 0.000000
APD 0.050000 0.000000 0.000000
ROP 0.050000 0.000000 0.000000
PSA 0.050000 0.000000 0.000000
V 0.050000 0.000000 0.000000
KO 0.050000 0.000000 0.000000
GOOGL 0.050000 0.000000 0.000000
AEE 0.050000 0.000000 0.000000

From the solution statistics, we see that with 20% turnover we could significantly increase the expected return while only slightly increasing the risk.

Takeaways#

  • A turnover constraint can be modeled using additional continuous decision variables for the amounts bought and sold.

  • Rebalancing is modeled in the same way as when we start from an all-cash position by considering the difference between the initial holdings and the final position in the rebalanced portfolio instead of just the final position.

[ ]: