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.


Sector Allocation

The standard mean-variance (Markowitz) portfolio selection model determines an optimal investment portfolio that balances risk and expected return. In this notebook, we minimize the variance (risk) of the portfolio given that the prescribed level of expected return is attained. Please refer to the annotated list of references for more background information on portfolio optimization.

The stock market comprises different sectors (Technology, Financial Services, Healthcare, etc.), industry groups, industries, and sub-industries and one often wants to limit exposure to each of them. In order to diversify investment risks across different industries, or to comply with regulatory requirements, one often needs to add sector allocation constraints to the basic model to limit

  • the number of open positions and

  • the total investment

in each sector.

[2]:
import gurobipy as gp
import gurobipy_pandas as gppd
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\)

  • \(M\): set of sectors. We use the 11 sectors in the Global Industry Classification Standard (GICS) classification.

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

We also import some data that contains the market capitalization and sector according to the GICS hierarchy for each of the stocks. We will need the total market capitalization and relative weight of each sector in the index.

[5]:
# Import stock data
data = pd.read_pickle("stock_data.pkl")

# Display sector data
sectors = (
    data[["Sector", "MarketCap"]].groupby(by=["Sector"]).aggregate(["sum", "count"])
)
sectors.columns = ["MarketCap", "Number"]
sectors["Weight"] = sectors["MarketCap"] / sectors["MarketCap"].sum()
sectors.sort_values("MarketCap", ascending=False)
[5]:
MarketCap Number Weight
Sector
Technology 12715607901184 65 0.286058
Communication Services 6054451107840 18 0.136205
Financial Services 5588576733184 64 0.125724
Healthcare 5272300330496 60 0.118609
Consumer Cyclical 4588151685632 53 0.103218
Industrials 3305283061760 67 0.074358
Consumer Defensive 2780607703040 34 0.062554
Energy 1569043430400 23 0.035298
Real Estate 931872515072 29 0.020964
Utilities 868188347904 29 0.019531
Basic Materials 777054908928 20 0.017481

For example, 65 stocks from our set belong to the Technology sector, which accounts for 28.6% of the total market capitalization.

For reference, we will compute the market portfolio as the portfolio that allocates into all stocks relative to the stocks’ market capitalization.

[6]:
x_market = data["MarketCap"] / data["MarketCap"].sum()
data_market = {
    "Variance": x_market @ Sigma @ x_market,
    "Expected return": x_market @ mu,
}
print(data_market)
{'Variance': 4.673079099420153, 'Expected return': 0.28157962440870105}

Formulation

The model minimizes the variance of the portfolio subject to

  • the portfolio matches the expected return of the market portfolio,

  • the size of each position does not fall below a certain level,

  • the total investment in each sector lies between specified lower and upper bounds,

  • the number of positions in each sector does not exceed a specified number.

Mathematically, this results in a convex quadratic mixed-integer optimization problem.

Model Parameters

We use the following parameters:

  • \(\bar\mu\): required expected portfolio return

  • \(\ell>0\): lower bound on position size

  • \(K_m\): maximal number of stocks in the portfolio from sector \(m\).

  • \(\ell_m, u_m\): lower and upper bounds on the weight of sector \(m\) in the portfolio. In this notebook, we will choose those bounds as a +/- 10% envelope around the sector weight in the S&P 500: That is, we choose \(\ell_m = (1-\delta)w_m, u_m = (1+\delta)w_m\), where \(w_m\) is the weight of sector \(m\) in the index.

[7]:
# Values for the model parameters:
r = data_market["Expected return"]  # Required return
l = 0.00001  # Minimal position size
K = 3  # Maximal number of stocks per sector
delta = 0.1  #  bound on the relative deviation from sector weight

Decision Variables

We need two sets 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. Binary variables \(b_i\) indicating whether or not asset \(i\) is held. If \(b_i\) is 0, the holding \(x_i\) is also 0; otherwise if \(b_i\) is 1, the investor holds asset \(i\) (that is, \(x_i \geq \ell\)).

Variable Bounds

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

\begin{equation*} 0\leq x_i\leq 1 \; , \; i \in S \end{equation*}

The \(b_i\) must be binary:

\begin{equation*} b_i \in \{0,1\} \; , \; i \in S \end{equation*}

We will model this using the gurobipy-pandas package. Using this, we create a DataFrame containing the decision variables as columns.

[8]:
# Create an empty optimization model
m = gp.Model()

# Add variables to model, align with data
df_model = (
    # x[i] denotes the proportion invested in stock i
    data.gppd.add_vars(m, name="x", ub=1)
    # b[i]=1 if stock i is held, and b[i]=0 otherwise
    .gppd.add_vars(m, name="b", vtype=gp.GRB.BINARY).drop(
        "Risk", axis=1
    )  # Not needed here
)

# A quick look at this DataFrame:
m.update()
df_model
[8]:
MarketCap Sector Name x b
MSFT 3004349808640 Technology Microsoft Corporation <gurobi.Var x[MSFT]> <gurobi.Var b[MSFT]>
AAPL 2893580337152 Technology Apple Inc. <gurobi.Var x[AAPL]> <gurobi.Var b[AAPL]>
AMZN 1750173089792 Consumer Cyclical Amazon.com, Inc. <gurobi.Var x[AMZN]> <gurobi.Var b[AMZN]>
NVDA 1684540096512 Technology NVIDIA Corporation <gurobi.Var x[NVDA]> <gurobi.Var b[NVDA]>
GOOGL 1789200171008 Communication Services Alphabet Inc. <gurobi.Var x[GOOGL]> <gurobi.Var b[GOOGL]>
... ... ... ... ... ...
RL 9448713216 Consumer Cyclical Ralph Lauren Corporation <gurobi.Var x[RL]> <gurobi.Var b[RL]>
SEE 4919490560 Consumer Cyclical Sealed Air Corporation <gurobi.Var x[SEE]> <gurobi.Var b[SEE]>
DVA 10014481408 Healthcare DaVita Inc. <gurobi.Var x[DVA]> <gurobi.Var b[DVA]>
ALK 4465086464 Industrials Alaska Air Group, Inc. <gurobi.Var x[ALK]> <gurobi.Var b[ALK]>
MHK 6813997568 Consumer Cyclical Mohawk Industries, Inc. <gurobi.Var x[MHK]> <gurobi.Var b[MHK]>

462 rows × 5 columns

Constraints

The budget constraint ensures that all capital is invested:

\begin{equation*} \sum_{i \in S} x_i =1 \end{equation*}

The expected return of the portfolio must be at least \(\bar\mu\):

\begin{equation*} \mu^\top x \geq \bar\mu \end{equation*}

We use functionality from pandas to create those constraints. Note that this ensures that the indices of df_model and mu are aligned.

[9]:
%%capture
# Budget constraint: all investments sum up to 1
m.addConstr(df_model["x"].sum() == 1, name="Budget_Constraint")

# Lower bound on expected return
m.addConstr(mu @ df_model["x"] >= r, "Required_Return")

From the bounds alone, \(x\) can take any value between \(0\) and \(1\). To enforce the minimal position size, we need the binary variables \(b\) and the following sets of discrete constraints:

Ensure that \(x_i = 0\) if \(b_i = 0\):

\begin{equation*} x_i \leq b_i \; , \; i \in S\tag{1} \end{equation*}

Note that since \(x_i\) has an upper bound of 1, the above constraint is non-restrictive if \(b_i = 1\).

Ensure a minimal position size of \(\ell\) if asset \(i\) is traded:

\begin{equation*} x_i \geq \ell b_i \; , \; i \in S\tag{2} \end{equation*}

Hence \(b_i = 1\) implies \(x_i \geq \ell\). Additionally, if \(b_i = 0\), the above constraint is non-restrictive since \(x_i\) has a lower bound of 0.

To add these constraints to the model, we use gurobipy-pandas functionality.

[10]:
%%capture
# Force x to 0 if not traded; see formula (1) above
# We use a gurobipy-pandas DataFrame accessor that takes the constraint as a string with column labels
df_model.gppd.add_constrs(m, "x <= b", name="Indicator")

# Minimal position; see formula (2) above
# We use a gurobipy-pandas function that takes the model, the left-hand side, sense, and right-hand side
gppd.add_constrs(
    m,
    df_model["x"],
    ">",
    l * df_model["b"],
    name="Minimal_Position",
)

Allocation constraints by sector

The weight of all stocks in sector \(m\) must be within the prescribed bounds:

\begin{equation*} \ell_m \leq \sum_{i\in m} x_i \leq u_m \; , \; m \in M\; , \tag{3} \end{equation*}

where \(\ell_m = (1-\delta)w_m\) and \(u_m = (1+\delta)w_m\).

Likewise, the number of stocks in sector \(m\) must not exceed \(K_m\). We use the binary variables \(b_i\) to count positions.

\begin{equation*} \sum_{i\in m} b_i \leq K_m \; , \; m \in M \tag{4} \end{equation*}

The DataFrame’s groupby method can be used to conveniently create sums over all stocks within the same sector:

[11]:
# Lower and upper bounds on position size per sector; see formula (3) above
sector_weight_ub = gppd.add_constrs(
    m,
    df_model.groupby("Sector")["x"].sum(),
    "<",
    sectors["Weight"] * (1 + delta),
    name="Sector_Weight_UB",
)
sector_weight_lb = gppd.add_constrs(
    m,
    df_model.groupby("Sector")["x"].sum(),
    ">",
    sectors["Weight"] * (1 - delta),
    name="Sector_Weight_LB",
)

# Upper bound on the number of positions per sector; see formula (4) above
sector_cardinality = gppd.add_constrs(
    m,
    df_model.groupby("Sector")["b"].sum(),
    "<",
    K,
    name="Sector_Cardinality",
)

Objective Function

The objective is to minimize the risk of the portfolio, which is measured by its variance:

\begin{equation*} \min_x x^\top \Sigma x \end{equation*}

[12]:
# Define objective function: Minimize risk
m.setObjective(df_model["x"] @ Sigma @ df_model["x"], gp.GRB.MINIMIZE)

We now solve the optimization problem:

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

CPU model: AMD EPYC 7763 64-Core Processor, instruction set [SSE2|AVX|AVX2]
Thread count: 1 physical cores, 2 logical processors, using up to 2 threads

WLS license 2443533 - registered to Gurobi GmbH
Optimize a model with 959 rows, 924 columns and 4158 nonzeros
Model fingerprint: 0xd130e4c6
Model has 106953 quadratic objective terms
Variable types: 462 continuous, 462 integer (462 binary)
Coefficient statistics:
  Matrix range     [1e-05, 1e+00]
  Objective range  [0e+00, 0e+00]
  QObjective range [6e-03, 2e+02]
  Bounds range     [1e+00, 1e+00]
  RHS range        [2e-02, 3e+00]
Presolve time: 0.04s
Presolved: 959 rows, 924 columns, 4157 nonzeros
Presolved model has 106953 quadratic objective terms
Variable types: 462 continuous, 462 integer (462 binary)
Found heuristic solution: objective 4.2808136

Root relaxation: objective 2.599011e+00, 212 iterations, 0.01 seconds (0.02 work units)

    Nodes    |    Current Node    |     Objective Bounds      |     Work
 Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time

     0     0    2.59901    0   35    4.28081    2.59901  39.3%     -    0s
H    0     0                       2.6251831    2.59901  1.00%     -    0s
     0     0    2.59901    0   33    2.62518    2.59901  1.00%     -    0s
     0     0    2.59901    0   32    2.62518    2.59901  1.00%     -    0s
H    0     0                       2.6225600    2.59901  0.90%     -    0s
     0     0    2.59901    0   31    2.62256    2.59901  0.90%     -    0s
     0     0    2.59901    0   29    2.62256    2.59901  0.90%     -    0s
     0     0    2.59901    0   29    2.62256    2.59901  0.90%     -    0s
     0     0    2.59901    0   29    2.62256    2.59901  0.90%     -    0s
     0     0    2.59901    0   29    2.62256    2.59901  0.90%     -    0s
     0     0    2.59901    0   29    2.62256    2.59901  0.90%     -    0s
     0     0    2.59901    0   29    2.62256    2.59901  0.90%     -    0s
     0     0    2.59901    0   29    2.62256    2.59901  0.90%     -    0s
     0     2    2.59901    0   29    2.62256    2.59901  0.90%     -    0s
H   39    10                       2.6225597    2.62091  0.06%   9.1    0s

Cutting planes:
  Gomory: 1
  Cover: 3
  MIR: 2
  Flow cover: 8

Explored 51 nodes (657 simplex iterations) in 0.44 seconds (0.25 work units)
Thread count was 2 (of 2 available processors)

Solution count 4: 2.62256 2.62256 2.62518 4.28081

Optimal solution found (tolerance 1.00e-04)
Best objective 2.622559688764e+00, best bound 2.622379368808e+00, gap 0.0069%

Display basic solution data based on the individual assets and sectors:

[14]:
data_model1 = {
    "Variance": m.ObjVal,
    "Expected return": mu @ df_model["x"].gppd.X,
}

print(f"Variance:         {m.ObjVal:.6f}")
print(f"Expected return:  {mu @ df_model['x'].gppd.X:.6f}")
print(f"Solution time:    {m.Runtime:.2f} seconds\n")

# Print investments (with non-negligible value, i.e. >1e-5)
data["Position"] = df_model["x"].gppd.X
data[data["Position"] > 1e-5][["MarketCap", "Sector", "Position"]].sort_values(
    "Position", ascending=False
)
Variance:         2.622560
Expected return:  0.281580
Solution time:    0.45 seconds

[14]:
MarketCap Sector Position
MSFT 3004349808640 Technology 0.129882
LLY 667918204928 Healthcare 0.119143
PGR 106073907200 Financial Services 0.096290
BR 23238772736 Technology 0.082739
TMUS 193721221120 Communication Services 0.063901
DPZ 14603976704 Consumer Cyclical 0.054687
WM 75618992128 Industrials 0.049627
TYL 17882144768 Technology 0.044831
AZO 48049000448 Consumer Cyclical 0.037813
TTWO 27903057920 Communication Services 0.035356
NOC 66327912448 Industrials 0.031669
KR 33348851712 Consumer Defensive 0.030470
CME 73643769856 Financial Services 0.029367
KDP 43670155264 Consumer Defensive 0.024210
CTRA 18142871552 Energy 0.023883
VZ 174259945472 Communication Services 0.023327
ED 31142295552 Utilities 0.021474
MCD 207836446720 Consumer Cyclical 0.020241
DLR 44423024640 Real Estate 0.018868
HRL 16470820864 Consumer Defensive 0.014130
MKTX 8191925760 Financial Services 0.012639
APD 49082589184 Basic Materials 0.008529
XOM 402421940224 Energy 0.007886
NEM 38533505024 Basic Materials 0.007204
GILD 95658491904 Healthcare 0.006410
MRK 318729060352 Healthcare 0.004917
RSG 54473105408 Industrials 0.000497
[15]:
# Display sector data
data_sectors = (
    data[data["Position"] > 1e-5].groupby("Sector")["Position"].agg(["sum", "count"])
)
data_sectors.columns = ["Weight", "Number"]
data_sectors.sort_values("Weight", ascending=False)
[15]:
Weight Number
Sector
Technology 0.257452 3
Financial Services 0.138296 3
Healthcare 0.130470 3
Communication Services 0.122584 3
Consumer Cyclical 0.112741 3
Industrials 0.081793 3
Consumer Defensive 0.068810 3
Energy 0.031768 2
Utilities 0.021474 1
Real Estate 0.018868 1
Basic Materials 0.015733 2

Comparison with the unconstrained portfolio

For comparison purposes, we will also compute the portfolio without the sector allocation constraints:

  • upper and lower bounds on the weight of each sector in the portfolio,

  • upper bound on the number of bought stocks in each sector.

To compute this, we relax the right-hand sides of those constraints enough to make them non-binding. In addition, we show the risk and return of the market porfolio that results from weighing all the of S&P500 stocks according to their market capitalization.

[16]:
# remove sector allocation constraints by relaxing the RHSs
sector_weight_ub.gppd.set_attr("RHS", 1)
sector_weight_lb.gppd.set_attr("RHS", 0)
sector_cardinality.gppd.set_attr("RHS", 100)

m.params.OutputFlag = 0
m.optimize()

data_model2 = {
    "Variance": m.ObjVal,
    "Expected return": mu @ df_model["x"].gppd.X,
}

df_results = pd.DataFrame(
    index=["with allocation constraints", "unconstrained", "market"],
    data=[data_model1, data_model2, data_market],
)
display(df_results)
Variance Expected return
with allocation constraints 2.622560 0.28158
unconstrained 2.344234 0.28158
market 4.673079 0.28158

From this comparison we see the effect of enforcing our sector allocation constraints:

  • In comparison with the unconstrained portfolio, the variance increases by about 15% by enforcing all sector allocation constraints. This is the “price” we have to pay for these portfolio features.

  • The optimized portfolios have greatly reduced variance in comparison with the market portfolio.

Finally we visualize the allocated investment sum for each stock being bought by either of the two optimized portfolios.

[17]:
# retrieve solution data and store in data
data["Position unconstr"] = df_model["x"].gppd.X

mask = (data["Position"] > 1e-5) | (data["Position unconstr"] > 1e-5)
df_positions = pd.DataFrame(
    index=data[mask].index,
    data={
        "sector allocation": data["Position"],
        "unconstrained": data["Position unconstr"],
    },
).sort_values(by=["sector allocation", "unconstrained"], ascending=True)

# plot data
axs = df_positions.plot.barh(color=["#0b1a3c", "#dd2113"])
axs.set_xlabel("Fraction of investment sum (per asset)")
plt.title("Portfolios with and without sector allocation constraints")
plt.show()
../_images/modeling_notebooks_sector_allocation_30_0.png

We can also compare the resulting sector allocation with the unconstrained portfolio and the market capitalization in the S&P 500 index:

[18]:
df_sectors = pd.DataFrame(
    index=data_sectors.index,
    data={
        "with constraints": data_sectors["Weight"],
        "S&P 500 market cap": sectors["Weight"],
        "unconstrained": data.groupby("Sector").sum()["Position unconstr"],
    },
).sort_values(by=["with constraints"], ascending=True)

axs = df_sectors.plot.barh(color=["#0b1a3c", "#f6c105", "#dd2113"])
axs.set_xlabel("Fraction of investment sum (per sector)")
plt.show()
../_images/modeling_notebooks_sector_allocation_32_0.png

One can see that without the allocation constraints, the resulting portfolio can be quite far from the distribution in the index. For example, in the S&P 500, the Technology sector has a weight of almost 29%, but the unconstrained portfolio almost completely avoids it due to its high variance. With the sector allocation constraints, we maintain the diversification from the index.

Takeaways

  • Constraints on the number of assets and total investment in each sector can be incorporated into the model.

  • Data from pandas DataFrames can easily be used to build an optimization model via the gurobipy-pandas package.

  • Sums over subsets of the variables can be defined using the DataFrame’s groupby method.