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.

Leverage by Borrowing Cash#

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.

To this basic model, we add leverage. Leverage means borrowing capital from a third party to buy more assets (and paying interest on the borrowed capital). This magnifies both the potential upside and downside.

import gurobipy as gp
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

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

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


Mathematically, this results in a convex quadratically constrained optimization problem.

Model Parameters#

The following parameters are used within the model:

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

  • \(c_\text{rf}\): interest on the risk-free asset. For simplicity, we assume the same interest rate for lending and borrowing.

  • \(\ell_\text{rf}\): maximal short on risk-free asset

  • \(u_\text{rf}\): maximal investment in risk-free asset

# Values for the model parameters:
V = 4.0  # Maximal admissible variance (sigma^2)
c_rf = 2 / 52  # interest rate on risk-free asset
l_rf = -0.3  # maximal borrowing of risk-free asset
u_rf = 1  # maximal investment in risk-free asset

Decision Variables#

We require two types of decision variables:

  1. The proportions of capital invested among the considered stocks. The corresponding vector of positions is denoted by \(x\) with its component \(x_i\) denoting the proportion of capital invested in stock \(i\).

  2. The proportion \(x_\text{rf}\) invested in the risk-free asset. This may be positive or negative. If positive, we gain a risk-free return; if negative, we pay interest on the borrowed amount.

Variable Bounds#

Each position must be nonnegative:

\[x_i\geq 0 \;, \, i \in S\]

The risk-free position must be within its bounds:

\[\ell_\text{rf} \leq x_\text{rf} \leq u_\text{rf}\]

Setting the upper bound \(u_\text{rf}=1\) means the portfolio is allowed to be fully invested in the risk-free asset.

# Create an empty optimization model
m = gp.Model()

# Add variables: x[i] denotes the proportion invested in stock i
x = m.addMVar(len(mu), name="x")

# Risk-free allocation
x_rf = m.addVar(lb=l_rf, ub=u_rf, name="x_rf")


The budget constraint ensures that all capital (both initial and borrowed) is invested:

\[\sum_{i \in S} x_i + x_\text{rf} = 1\]

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

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

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

Objective Function#

The objective is to maximize the expected return of the portfolio. We need to account for risk-free returns and costs for borrowing cash: \begin{equation*} \max_x \underbrace{c_\text{rf} x_\text{rf}}_{\substack{\text{cost for borrowing}\\\text{or risk-free return}}} + \underbrace{\mu^\top x}_\text{expected return from stocks} \end{equation*}

m.setObjective(c_rf * x_rf + mu.to_numpy() @ x, gp.GRB.MAXIMIZE)

We now solve the optimization problem:

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 1 rows, 463 columns and 463 nonzeros
Model fingerprint: 0xc5359fbf
Model has 1 quadratic constraint
Coefficient statistics:
  Matrix range     [1e+00, 1e+00]
  QMatrix range    [3e-03, 1e+02]
  Objective range  [4e-02, 6e-01]
  Bounds range     [3e-01, 1e+00]
  RHS range        [1e+00, 1e+00]
  QRHS range       [4e+00, 4e+00]
Presolve time: 0.03s
Presolved: 464 rows, 926 columns, 107879 nonzeros
Presolved model has 1 second-order cone constraint
Ordering time: 0.01s

Barrier statistics:
 AA' NZ     : 1.074e+05
 Factor NZ  : 1.079e+05 (roughly 1 MB of memory)
 Factor Ops : 3.341e+07 (less than 1 second per iteration)
 Threads    : 2

                  Objective                Residual
Iter       Primal          Dual         Primal    Dual     Compl     Time
   0   1.36004448e+01  1.52173653e-01  5.78e+01 6.79e-01  4.09e-02     0s
   1   1.85351518e+00  1.75506108e+00  6.76e+00 7.47e-07  6.08e-03     0s
   2   5.30037596e-01  9.57190813e-01  1.20e+00 1.38e-07  1.49e-03     0s
   3   2.55706808e-01  5.45041563e-01  1.32e-06 2.05e-08  3.12e-04     0s
   4   2.97986703e-01  4.47766523e-01  1.45e-12 4.74e-10  1.61e-04     0s
   5   3.51518301e-01  3.94493222e-01  6.44e-15 1.58e-10  4.63e-05     0s
   6   3.59709812e-01  3.65384265e-01  2.11e-14 2.21e-11  6.11e-06     0s
   7   3.61347697e-01  3.61805875e-01  4.11e-15 1.22e-12  4.94e-07     0s
   8   3.61595509e-01  3.61671962e-01  8.10e-14 6.36e-14  8.24e-08     0s
   9   3.61660538e-01  3.61666698e-01  1.96e-12 1.94e-15  6.64e-09     0s
  10   3.61665713e-01  3.61666153e-01  1.45e-11 2.44e-14  4.74e-10     0s

Barrier solved model in 10 iterations and 0.22 seconds (0.36 work units)
Optimal objective 3.61665713e-01

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

print(f"Expected return: {m.ObjVal:.6f}")
print(f"Variance:        {x.X @ Sigma @ x.X:.6f}")
print(f"Solution time:   {m.Runtime:.2f} seconds\n")

print(f"Total investment:     {x.X[x.X>1e-5].sum():.6f}")
print(f"Risk-free allocation: {x_rf.X:.6f}")
print(f"Number of positions:  {np.count_nonzero(x.X[abs(x.X)>1e-5])}")

# Print all assets with a non-negligible position
df = pd.DataFrame(
        "x": x.X,
df[(abs(df["x"]) > 1e-5)].sort_values("x", ascending=False)
Expected return: 0.361666
Variance:        3.999998
Solution time:   0.22 seconds

Total investment:     1.180825
Risk-free allocation: -0.180830
Number of positions:  31
LLY 0.234543
PGR 0.130482
KDP 0.109879
TMUS 0.061029
NVDA 0.059540
KR 0.059145
DPZ 0.058683
TTWO 0.052532
WM 0.050017
NOC 0.049275
ODFL 0.039827
ORLY 0.035947
AVGO 0.034790
WST 0.029045
MSFT 0.027447
ED 0.019026
MKTX 0.018752
AZO 0.018154
MNST 0.015003
CLX 0.014767
META 0.013703
HRL 0.010081
NFLX 0.009694
WMT 0.008271
UNH 0.007755
XEL 0.004653
DXCM 0.004488
CBOE 0.003343
MOH 0.000762
WEC 0.000171
CME 0.000020

Comparison with the unconstrained portfolio without leverage#

We can also compute the portfolio without leverage and compare the resulting portfolios.

# adjust RHS of short constraint = 0
x_rf.ub = 0
m.params.OutputFlag = 0

# retrieve and display solution data
mask = (abs(df["x"]) > 1e-5) | (x.X > 1e-5)
df2 = pd.DataFrame(
        "risk-free asset in [-0.3, 1]": df["x"],
        "no risk-free asset": x.X[mask],
).sort_values(by=["risk-free asset in [-0.3, 1]"], ascending=True)

axs = df2.plot.barh(color=["#0b1a3c", "#dd2113"])
axs.set_xlabel("Fraction of investment sum")
plt.title("Minimum Variance portfolios with and without leverage")

Efficient Frontiers#

The efficient frontier reveals the balance between risk and return in investment portfolios. It shows the best-expected return level that can be achieved for a specified risk level. We compute this by solving the above optimization problem for a sample of admissible risk levels with and without the risk-free asset. When we restrict investment amounts in the risk-free asset, that is \(u_\text{rf} < 1\), the model may be infeasible for very small risk levels.

risks = np.linspace(0, 6, 30)
rf_bnds = [(0, 0), (0, 1), (-0.3, 0), (-1, -0.2)]

returns = pd.DataFrame(index=risks)

# prevent Gurobi log output
m.params.OutputFlag = 0

for lb, ub in rf_bnds:
    name = f"[{lb}, {ub}]"
    x_rf.LB = lb
    x_rf.UB = ub

    r = np.zeros(risks.shape)
    # solve the model for each risk level
    for i, risk_level in enumerate(risks):
        # set risk level: RHS of risk constraint
        risk_constr.QCRHS = risk_level**2

        # check status and store data
        if m.Status == gp.GRB.OPTIMAL:
            r[i] = m.ObjVal
            r[i] = float("NaN")

    returns[name] = r

We can display the efficient frontiers for all strategies. We plot the expected returns (on the \(y\)-axis) against the standard deviation \(\sqrt{x^\top\Sigma x}\) of the expected returns (on the \(x\)-axis).

colors = ["#0b1a3c", "#67a1c3", "#f6c105", "#dd2113"]
markers = ["o", "x", "x", "x"]
fig, axs = plt.subplots()

for column, color, marker in zip(returns.columns, colors, markers):
    axs.scatter(x=returns.index, y=returns[column], marker=marker, color=color)
    label = (
        "without risk-free asset"
        if column == "[0, 0]"
        else f"risk-free asset in {column}"
axs.set_xlabel("Standard deviation")
axs.set_ylabel("Expected return")

If we allow investing in a risk-free asset, the portfolio variance can be arbitrarily small. If one invests all capital into the risk-free asset, the variance (and standard deviation) is 0. Without that possibility (i.e., \(x_\text{rf}\leq 0\)), the minimal possible risk is greater than 0. If we can invest in the risk-free asset, the left-most part of the efficient frontier is a straight line from the portfolio that invests the entire capital into the risk-free asset to the minimal-variance portfolio. If we allow higher risk, including the ability to borrow cash, this shifts the efficient frontier towards higher returns.


  • Leverage can be modeled by adding a variable for the risk-free portion that can take negative values.

  • Different strategies can be tested by modifying bounds and right-hand sides; there is no need to rebuild the model.