{ "cells": [ { "cell_type": "markdown", "id": "39917d70", "metadata": {}, "source": [ "# Buying Round Lots\n", "\n", "The *standard mean-variance (Markowitz) portfolio selection model* determines the optimal investments by balancing 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](../literature.rst#portfolio-optimization) for more background information on portfolio optimization.\n", "\n", "Securities on the stock market are often traded in *round lots*. A round lot is a fixed number of units (that typically depends on the financial instrument that is traded). For example, stocks are often traded in multiples of 100 shares. Any smaller quantity of traded securities is called an *odd lot*, which typically induces higher transaction costs, or slower order execution. Also, to avoid small positions, one might want to ensure that a *minimum number of units* is traded if a position is opened.\n", "\n", "In this notebook, we add the following constraints to the basic model:\n", "\n", "* If a position is opened, it must comprise a minimum number of shares and,\n", "* stocks can only be bought in round lots.\n", "\n", "For our example, we will be using a lower bound of 1000 shares per asset and a uniform lot size of 100 shares.\n", "\n", "We also include a risk-free asset in the model." ] }, { "cell_type": "code", "execution_count": 1, "id": "f73cf4f8", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:10.765354Z", "iopub.status.busy": "2025-01-31T10:06:10.765097Z", "iopub.status.idle": "2025-01-31T10:06:11.552517Z", "shell.execute_reply": "2025-01-31T10:06:11.551741Z" }, "nbsphinx": "hidden" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Requirement already satisfied: numpy in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (2.2.2)\r\n", "Requirement already satisfied: scipy in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (1.15.1)\r\n", "Requirement already satisfied: gurobipy in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (11.0.3)\r\n", "Requirement already satisfied: pandas in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (2.2.3)\r\n", "Requirement already satisfied: matplotlib in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (3.10.0)\r\n", "Requirement already satisfied: python-dateutil>=2.8.2 in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (from pandas) (2.9.0.post0)\r\n", "Requirement already satisfied: pytz>=2020.1 in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (from pandas) (2025.1)\r\n", "Requirement already satisfied: tzdata>=2022.7 in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (from pandas) (2025.1)\r\n", "Requirement already satisfied: contourpy>=1.0.1 in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (from matplotlib) (1.3.1)\r\n", "Requirement already satisfied: cycler>=0.10 in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (from matplotlib) (0.12.1)\r\n", "Requirement already satisfied: fonttools>=4.22.0 in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (from matplotlib) (4.55.8)\r\n", "Requirement already satisfied: kiwisolver>=1.3.1 in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (from matplotlib) (1.4.8)\r\n", "Requirement already satisfied: packaging>=20.0 in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (from matplotlib) (24.2)\r\n", "Requirement already satisfied: pillow>=8 in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (from matplotlib) (11.1.0)\r\n", "Requirement already satisfied: pyparsing>=2.3.1 in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (from matplotlib) (3.2.1)\r\n", "Requirement already satisfied: six>=1.5 in /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)\r\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Note: you may need to restart the kernel to use updated packages.\n" ] } ], "source": [ "# Install dependencies\n", "%pip install numpy scipy gurobipy pandas matplotlib" ] }, { "cell_type": "code", "execution_count": 2, "id": "0a890d90", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:11.554776Z", "iopub.status.busy": "2025-01-31T10:06:11.554554Z", "iopub.status.idle": "2025-01-31T10:06:12.197142Z", "shell.execute_reply": "2025-01-31T10:06:12.196454Z" } }, "outputs": [], "source": [ "import gurobipy as gp\n", "import gurobipy_pandas as gppd\n", "import pandas as pd\n", "import numpy as np\n", "import matplotlib.pyplot as plt" ] }, { "cell_type": "code", "execution_count": 3, "id": "48d33278", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:12.199468Z", "iopub.status.busy": "2025-01-31T10:06:12.199041Z", "iopub.status.idle": "2025-01-31T10:06:12.207562Z", "shell.execute_reply": "2025-01-31T10:06:12.206993Z" }, "nbsphinx": "hidden" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Set parameter WLSAccessID\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Set parameter WLSSecret\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Set parameter LicenseID to value 2443533\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "WLS license 2443533 - registered to Gurobi GmbH\n" ] } ], "source": [ "# Hidden cell to avoid licensing messages\n", "# when docs are generated.\n", "with gp.Model():\n", " pass" ] }, { "cell_type": "markdown", "id": "c0e249a4", "metadata": {}, "source": [ "## Input Data\n", "\n", "The following input data is used within the model:\n", "\n", "- $S$: set of stocks\n", "- $p_i$: last price of stock $i$ in USD\n", "- $\\mu$: vector of expected returns\n", "- $\\Sigma$: PSD variance-covariance matrix\n", " - $\\sigma_{ij}$ covariance between returns of assets $i$ and $j$\n", " - $\\sigma_{ii}$ variance of return of asset $i$" ] }, { "cell_type": "code", "execution_count": 4, "id": "d6ef0daa", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:12.209398Z", "iopub.status.busy": "2025-01-31T10:06:12.209221Z", "iopub.status.idle": "2025-01-31T10:06:12.213935Z", "shell.execute_reply": "2025-01-31T10:06:12.213370Z" } }, "outputs": [], "source": [ "# Import some example data set\n", "Sigma = pd.read_pickle(\"sigma.pkl\")\n", "mu = pd.read_pickle(\"mu.pkl\")" ] }, { "cell_type": "markdown", "id": "1b56be8e", "metadata": {}, "source": [ "We also import the prices of the assets:" ] }, { "cell_type": "code", "execution_count": 5, "id": "4a3173ce", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:12.215775Z", "iopub.status.busy": "2025-01-31T10:06:12.215585Z", "iopub.status.idle": "2025-01-31T10:06:12.221052Z", "shell.execute_reply": "2025-01-31T10:06:12.220483Z" } }, "outputs": [], "source": [ "# Import price data\n", "prices = pd.read_pickle(\"subset_weekly_closings_10yrs.pkl\").tail(1).squeeze()\n", "data = pd.DataFrame(data={\"Price\": prices})" ] }, { "cell_type": "markdown", "id": "5e90a0cb", "metadata": {}, "source": [ "## Formulation\n", "The model minimizes the variance of the portfolio given that the minimum level of expected return is attained. Also\n", "* shares can only be bought in multiples of a lot size $l$, and\n", "* if a position in an asset is bought, it must comprise at least $L$ lots (and hence at least $L\\cdot l$ shares).\n", "\n", "Mathematically, this results in a convex quadratic mixed-integer optimization problem.\n", "\n", "### Model Parameters\n", "\n", "We use the following parameters:\n", "\n", "- $\\bar\\mu$: required expected portfolio return\n", "- $\\mu_\\text{rf}$: risk-free return\n", "- $T$: total investment amount in USD (AUM)\n", "- $L$: minimal number of lots per asset\n", "- $l$: lot size" ] }, { "cell_type": "code", "execution_count": 6, "id": "3d18be8e", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:12.222959Z", "iopub.status.busy": "2025-01-31T10:06:12.222778Z", "iopub.status.idle": "2025-01-31T10:06:12.225684Z", "shell.execute_reply": "2025-01-31T10:06:12.225270Z" } }, "outputs": [], "source": [ "# Values for the model parameters:\n", "r = 0.25 # Required return\n", "mu_rf = 0.5 / 52 # Risk-free return rate\n", "T = 1e7 # total investment amount\n", "L = 10 # minimal number of lots\n", "l = 100 # lot size" ] }, { "cell_type": "markdown", "id": "85552527", "metadata": {}, "source": [ "### Decision Variables\n", "We need three types of decision variables:\n", "\n", "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$.\n", "\n", "2. The proportion of capital invested into the risk-free asset is denoted by $x_\\text{rf}$.\n", "\n", "3. The *number of lots* of shares bought of the considered stocks. The corresponding vector is denoted by $z$ with its component $z_i$ denoting the number of lots in stock $i$. Note that the number of shares in asset $i$ is then $lz_i$.\n", "\n", "### Variable Bounds\n", "\n", "Each position must be between 0 and 1; this prevents leverage and short-selling:\n", "\n", "$$0\\leq x_i, x_\\text{rf}\\leq 1 \\; , \\; i \\in S$$\n", "\n", "The $z_i$ must be integers. To enforce the minimal number $L$ of lots if an asset is bought, we will declare those variables *semi-integer*. That is,\n", "\n", "$$z_i \\in \\mathbb Z\\ \\text{and}\\ z_i=0\\ \\text{or}\\ 0< L \\leq z_i \\;, \\; i \\in S$$\n", "\n", "\n", "We will model this using the [gurobipy-pandas](https://github.com/Gurobi/gurobipy-pandas) package. Using this, we first create an extended DataFrame containing the decision variables." ] }, { "cell_type": "code", "execution_count": 7, "id": "996a9dc0", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:12.227446Z", "iopub.status.busy": "2025-01-31T10:06:12.227248Z", "iopub.status.idle": "2025-01-31T10:06:12.250270Z", "shell.execute_reply": "2025-01-31T10:06:12.249838Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", " | Price | \n", "x | \n", "z | \n", "
---|---|---|---|
APTV | \n", "79.150002 | \n", "<gurobi.Var x[APTV]> | \n", "<gurobi.Var z[APTV]> | \n", "
DVN | \n", "44.290001 | \n", "<gurobi.Var x[DVN]> | \n", "<gurobi.Var z[DVN]> | \n", "
HSY | \n", "194.528000 | \n", "<gurobi.Var x[HSY]> | \n", "<gurobi.Var z[HSY]> | \n", "
CAG | \n", "28.020000 | \n", "<gurobi.Var x[CAG]> | \n", "<gurobi.Var z[CAG]> | \n", "
HST | \n", "16.980000 | \n", "<gurobi.Var x[HST]> | \n", "<gurobi.Var z[HST]> | \n", "
... | \n", "... | \n", "... | \n", "... | \n", "
AEE | \n", "76.779999 | \n", "<gurobi.Var x[AEE]> | \n", "<gurobi.Var z[AEE]> | \n", "
AAPL | \n", "187.440002 | \n", "<gurobi.Var x[AAPL]> | \n", "<gurobi.Var z[AAPL]> | \n", "
AIZ | \n", "159.820007 | \n", "<gurobi.Var x[AIZ]> | \n", "<gurobi.Var z[AIZ]> | \n", "
UNP | \n", "215.690002 | \n", "<gurobi.Var x[UNP]> | \n", "<gurobi.Var z[UNP]> | \n", "
K | \n", "52.200001 | \n", "<gurobi.Var x[K]> | \n", "<gurobi.Var z[K]> | \n", "
462 rows × 3 columns
\n", "\n", " | Position | \n", "Shares | \n", "Price | \n", "
---|---|---|---|
LLY | \n", "0.153020 | \n", "2600.0 | \n", "588.539978 | \n", "
KDP | \n", "0.078814 | \n", "24800.0 | \n", "31.780001 | \n", "
PGR | \n", "0.075682 | \n", "4800.0 | \n", "157.669998 | \n", "
NVDA | \n", "0.048888 | \n", "1000.0 | \n", "488.880005 | \n", "
DPZ | \n", "0.048716 | \n", "1300.0 | \n", "374.739990 | \n", "
NOC | \n", "0.046388 | \n", "1000.0 | \n", "463.880005 | \n", "
ODFL | \n", "0.039779 | \n", "1000.0 | \n", "397.790009 | \n", "
TMUS | \n", "0.039693 | \n", "2700.0 | \n", "147.009995 | \n", "
KR | \n", "0.035408 | \n", "8300.0 | \n", "42.660000 | \n", "
TTWO | \n", "0.035282 | \n", "2300.0 | \n", "153.399994 | \n", "
WST | \n", "0.034423 | \n", "1000.0 | \n", "344.230011 | \n", "
WM | \n", "0.034234 | \n", "2000.0 | \n", "171.169998 | \n", "
ED | \n", "0.028055 | \n", "3100.0 | \n", "90.500000 | \n", "
MKTX | \n", "0.022670 | \n", "1000.0 | \n", "226.699997 | \n", "
WMT | \n", "0.021846 | \n", "1400.0 | \n", "156.039993 | \n", "
CLX | \n", "0.021845 | \n", "1600.0 | \n", "136.529999 | \n", "
CME | \n", "0.021106 | \n", "1000.0 | \n", "211.059998 | \n", "
HRL | \n", "0.016003 | \n", "4900.0 | \n", "32.660000 | \n", "
MNST | \n", "0.014326 | \n", "2600.0 | \n", "55.099998 | \n", "
XEL | \n", "0.008413 | \n", "1400.0 | \n", "60.090000 | \n", "
CPB | \n", "0.006480 | \n", "1600.0 | \n", "40.500000 | \n", "