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:

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:

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\).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:

The buy and sell proportions must be non-negative:

```
[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:

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\):

```
[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

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

Accordingly, the turnover constraint

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:

```
[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.

```
[ ]:
```

```
```