{ "cells": [ { "cell_type": "markdown", "id": "d4de6864", "metadata": {}, "source": [ "# Limiting Turnover\n", "\n", "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](../literature.rst#portfolio-optimization) for more background information on portfolio optimization.\n", "\n", "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.\n", "Additionally, turnover constraints often mitigate what is sometimes referred to as the error-maximizing property of the classical mean-variance portfolio.\n", "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." ] }, { "cell_type": "code", "execution_count": 1, "id": "62af373c", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:38.161692Z", "iopub.status.busy": "2025-01-31T10:06:38.161462Z", "iopub.status.idle": "2025-01-31T10:06:38.957692Z", "shell.execute_reply": "2025-01-31T10:06:38.956936Z" }, "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" ] }, { "name": "stdout", "output_type": "stream", "text": [ "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": "44231f2d", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:38.959846Z", "iopub.status.busy": "2025-01-31T10:06:38.959636Z", "iopub.status.idle": "2025-01-31T10:06:39.393848Z", "shell.execute_reply": "2025-01-31T10:06:39.393312Z" } }, "outputs": [], "source": [ "import gurobipy as gp\n", "import pandas as pd\n", "import numpy as np\n", "import scipy.linalg as la\n", "import random" ] }, { "cell_type": "code", "execution_count": 3, "id": "b030fd87", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:39.396587Z", "iopub.status.busy": "2025-01-31T10:06:39.396309Z", "iopub.status.idle": "2025-01-31T10:06:39.404683Z", "shell.execute_reply": "2025-01-31T10:06:39.404076Z" }, "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": "a2b8af49", "metadata": {}, "source": [ "## Input Data\n", "\n", "The following input data is used within the model:\n", "\n", "- $S$: set of stocks\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": "1502a5f5", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:39.406582Z", "iopub.status.busy": "2025-01-31T10:06:39.406400Z", "iopub.status.idle": "2025-01-31T10:06:39.411320Z", "shell.execute_reply": "2025-01-31T10:06:39.410727Z" } }, "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": "685d6cb1", "metadata": {}, "source": [ "## Formulation\n", "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.\n", "\n", "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.\n", "\n", "### Model Parameters\n", "\n", "The following parameters are used within the model:\n", "\n", "- $\\bar\\sigma^2$: maximal admissible variance for the portfolio return\n", "- $\\tau$: maximal turnover rate\n", "- $x^0_i$: holdings of asset $i$ in the initial portfolio.\n", "Usually, the initial portfolio is given by the previous trading period.\n", "In this notebook, we choose a simple initial portfolio by heuristically approximating the minimum variance portfolio on at most 20 assets." ] }, { "cell_type": "code", "execution_count": 5, "id": "864c8795", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:39.413196Z", "iopub.status.busy": "2025-01-31T10:06:39.412985Z", "iopub.status.idle": "2025-01-31T10:06:39.423972Z", "shell.execute_reply": "2025-01-31T10:06:39.423437Z" } }, "outputs": [], "source": [ "# Values for the model parameters:\n", "V = 4.0 # Maximal admissible variance (sigma^2)\n", "tau = 0.2 # Maximal turnover rate\n", "\n", "# Initial portfolio\n", "tmp = pd.Series(index=mu.index, data=la.solve(Sigma, np.ones(mu.shape), assume_a=\"pos\"))\n", "x0 = pd.Series(index=mu.index, data=np.zeros(mu.shape))\n", "x0.loc[tmp.nlargest(20).index] = 1.0 / 20.0" ] }, { "cell_type": "markdown", "id": "f4a7a670", "metadata": {}, "source": [ "### Decision Variables\n", "We need three sets of decision variables:\n", "\n", "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$.\n", "\n", "The other sets of variables distinguish between buys and sales:\n", "\n", "2. 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$.\n", "\n", "3. 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$.\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\\leq 1 \\; , \\; i \\in S$$\n", "\n", "The buy and sell proportions must be non-negative:\n", "\n", "$$ x_i^+, x_i^- \\geq 0\\; , \\, i \\in S$$" ] }, { "cell_type": "code", "execution_count": 6, "id": "2dd35b60", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:39.426678Z", "iopub.status.busy": "2025-01-31T10:06:39.426475Z", "iopub.status.idle": "2025-01-31T10:06:39.434772Z", "shell.execute_reply": "2025-01-31T10:06:39.434214Z" } }, "outputs": [], "source": [ "%%capture\n", "# Create an empty optimization model\n", "m = gp.Model()\n", "\n", "# Add variables: x[i] denotes the proportion invested in stock i\n", "x = m.addMVar(len(mu), lb=0, ub=1, name=\"x\")\n", "# Add variables: x_plus[i] denotes the proportion of stock i bought\n", "x_plus = m.addMVar(len(mu), lb=0, ub=1, name=\"x_plus\")\n", "# Add variables: x_minus[i] denotes the proportion of stock i sold\n", "x_minus = m.addMVar(len(mu), lb=0, ub=1, name=\"x_minus\")" ] }, { "cell_type": "markdown", "id": "579944bf", "metadata": {}, "source": [ "### Constraints\n", "\n", "The budget constraint ensures that all capital is invested:\n", "\n", "$$\\sum_{i \\in S} x_i =1 $$\n", "\n", "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:\n", "\\begin{equation*}\n", "x_i = x^0_i + x^+_i - x^-_i \\; , \\; i \\in S \\tag{1}\n", "\\end{equation*}\n", "\n", "The estimated risk must not exceed a prespecified maximal admissible level of variance $\\bar\\sigma^2$:\n", "\n", "$$x^\\top \\Sigma x \\leq \\bar\\sigma^2$$" ] }, { "cell_type": "code", "execution_count": 7, "id": "4e3313cc", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:39.437938Z", "iopub.status.busy": "2025-01-31T10:06:39.437244Z", "iopub.status.idle": "2025-01-31T10:06:39.489674Z", "shell.execute_reply": "2025-01-31T10:06:39.489072Z" } }, "outputs": [], "source": [ "%%capture\n", "# Budget constraint: all investments sum up to 1\n", "m.addConstr(x.sum() == 1, name=\"Budget_Constraint\")\n", "\n", "# Position rebalancing constraint, see formula (1) above\n", "m.addConstr(x == x0.to_numpy() + x_plus - x_minus, name=\"Position_Balance\")\n", "\n", "# Upper bound on variance\n", "m.addConstr(x @ Sigma.to_numpy() @ x <= V, name=\"Variance\")" ] }, { "cell_type": "markdown", "id": "75b396f3", "metadata": {}, "source": [ "#### Turnover\n", "The *turnover ratio* is defined as the sum of the absolute values of the differences between the positions in the initial and rebalanced portfolios.\n", "\n", "Denoting by $x_i^0$ the position in asset $i$ in the initial portfolio and by $x_i$ the position in the rebalanced portfolio,\n", "the turnover ratio is defined as\n", "\n", "$$\\sum_{i \\in S} |x_i - x^0_i| $$\n", "\n", "and represents the absolute difference between the holdings in the two portfolios.\n", "\n", "Accordingly, the turnover constraint\n", "\n", "$$ \\sum_{i \\in S} |x_i - x^0_i| \\leq \\tau $$\n", "\n", "is nonconvex.\n", "\n", "\n", "#### Linearization of Turnover Constraint\n", "\n", "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:\n", "\\begin{equation*}\n", "\\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\n", "\\end{equation*}\n", "Therefore, the turnover constraint can be replaced with the following linear constraint:\n", "\n", "\\begin{equation*}\n", "\\sum_{i \\in S} x^+_i + \\sum_{i \\in S} x^-_i \\leq \\tau\\tag{2}\n", "\\end{equation*}" ] }, { "cell_type": "code", "execution_count": 8, "id": "4dbe5769", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:39.492223Z", "iopub.status.busy": "2025-01-31T10:06:39.491997Z", "iopub.status.idle": "2025-01-31T10:06:39.498307Z", "shell.execute_reply": "2025-01-31T10:06:39.497774Z" } }, "outputs": [], "source": [ "%%capture\n", "# Linearized turnover constraint; see formula (2) above\n", "m.addConstr(x_plus.sum() + x_minus.sum() <= tau, name=\"Turnover\")" ] }, { "cell_type": "markdown", "id": "5d1e5092", "metadata": {}, "source": [ "### Objective Function\n", "The objective is to maximize the expected return of the portfolio:\n", "$$\\max \\mu^\\top x $$" ] }, { "cell_type": "code", "execution_count": 9, "id": "d4a83ee3", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:39.501297Z", "iopub.status.busy": "2025-01-31T10:06:39.500569Z", "iopub.status.idle": "2025-01-31T10:06:39.504780Z", "shell.execute_reply": "2025-01-31T10:06:39.504314Z" } }, "outputs": [], "source": [ "# Define objective: total expected return\n", "m.setObjective(mu.to_numpy() @ x, gp.GRB.MAXIMIZE)" ] }, { "cell_type": "markdown", "id": "99db759f", "metadata": {}, "source": [ "We now solve the optimization problem:" ] }, { "cell_type": "code", "execution_count": 10, "id": "aa0e24f4", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:39.506887Z", "iopub.status.busy": "2025-01-31T10:06:39.506712Z", "iopub.status.idle": "2025-01-31T10:06:39.910297Z", "shell.execute_reply": "2025-01-31T10:06:39.909669Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Gurobi Optimizer version 11.0.3 build v11.0.3rc0 (linux64 - \"Ubuntu 24.04.1 LTS\")\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "CPU model: AMD EPYC 7763 64-Core Processor, instruction set [SSE2|AVX|AVX2]\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Thread count: 1 physical cores, 2 logical processors, using up to 2 threads\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "WLS license 2443533 - registered to Gurobi GmbH\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Optimize a model with 464 rows, 1386 columns and 2772 nonzeros\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Model fingerprint: 0x0a08960d\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Model has 1 quadratic constraint\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Coefficient statistics:\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " Matrix range [1e+00, 1e+00]\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " QMatrix range [3e-03, 1e+02]\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " Objective range [7e-02, 6e-01]\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " Bounds range [1e+00, 1e+00]\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " RHS range [5e-02, 1e+00]\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " QRHS range [4e+00, 4e+00]\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Presolve time: 0.05s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Presolved: 927 rows, 1849 columns, 110188 nonzeros\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Presolved model has 1 second-order cone constraint\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Ordering time: 0.01s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Barrier statistics:\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " AA' NZ : 2.153e+05\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " Factor NZ : 2.423e+05 (roughly 3 MB of memory)\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " Factor Ops : 9.108e+07 (less than 1 second per iteration)\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " Threads : 1\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " Objective Residual\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Iter Primal Dual Primal Dual Compl Time\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " 0 1.44527363e+01 1.72963108e-01 1.22e+02 5.99e-01 3.17e-02 0s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " 1 1.65640068e+00 1.46901369e+00 1.19e+01 6.59e-07 3.44e-03 0s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " 2 4.89775581e-01 5.45484369e-01 2.30e+00 9.88e-08 7.15e-04 0s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " 3 2.92096090e-01 3.67079460e-01 6.57e-01 2.88e-08 2.16e-04 0s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " 4 2.06748545e-01 3.11821542e-01 6.95e-03 7.96e-09 3.41e-05 0s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " 5 2.10372783e-01 2.57330220e-01 9.18e-04 2.99e-09 1.46e-05 0s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " 6 2.27467697e-01 2.50653410e-01 5.64e-10 1.52e-10 7.16e-06 0s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " 7 2.38727034e-01 2.42663937e-01 1.28e-10 5.71e-12 1.22e-06 0s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " 8 2.42005053e-01 2.42499892e-01 7.24e-12 5.00e-16 1.53e-07 0s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " 9 2.42337440e-01 2.42375930e-01 1.99e-13 6.66e-16 1.19e-08 0s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " 10 2.42343948e-01 2.42344877e-01 3.50e-11 5.00e-16 2.87e-10 0s\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Barrier solved model in 10 iterations and 0.39 seconds (0.70 work units)\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Optimal objective 2.42343948e-01\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "\n" ] } ], "source": [ "m.optimize()" ] }, { "cell_type": "markdown", "id": "d6d214dd", "metadata": {}, "source": [ "Display basic solution data; for clarity we've rounded all solution quantities to five digits." ] }, { "cell_type": "code", "execution_count": 11, "id": "b628d4a7", "metadata": { "execution": { "iopub.execute_input": "2025-01-31T10:06:39.912293Z", "iopub.status.busy": "2025-01-31T10:06:39.912081Z", "iopub.status.idle": "2025-01-31T10:06:39.929104Z", "shell.execute_reply": "2025-01-31T10:06:39.928500Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Expected return before/after: 0.201269/0.242344\n", "Variance before/after: 3.844733/3.999991\n", "Solution time: 0.39 seconds\n", "\n", "Number of positions: before 20, after 23\n", "Number of buys: 5\n", "Number of sells: 2\n", "Total turnover: 0.200000\n", "\n" ] }, { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
positiontransactionturnover
NVDA0.0702610.0702610.070261
PEAK0.000000-0.0500000.050000
TFC0.000000-0.0500000.050000
TSLA0.0117800.0117800.011780
ENPH0.0067590.0067590.006759
LLY0.0065140.0065140.006514
DXCM0.0046870.0046870.004687
EG0.050000-0.0000000.000000
RF0.050000-0.0000000.000000
WTW0.050000-0.0000000.000000
NVR0.0500000.0000000.000000
CL0.050000-0.0000000.000000
DUK0.050000-0.0000000.000000
CMA0.050000-0.0000000.000000
ED0.050000-0.0000000.000000
FRT0.050000-0.0000000.000000
STT0.050000-0.0000000.000000
AMCR0.050000-0.0000000.000000
APD0.050000-0.0000000.000000
ROP0.050000-0.0000000.000000
PSA0.050000-0.0000000.000000
V0.0500000.0000000.000000
KO0.050000-0.0000000.000000
GOOGL0.0500000.0000000.000000
AEE0.0500000.0000000.000000
\n", "
" ], "text/plain": [ " position transaction turnover\n", "NVDA 0.070261 0.070261 0.070261\n", "PEAK 0.000000 -0.050000 0.050000\n", "TFC 0.000000 -0.050000 0.050000\n", "TSLA 0.011780 0.011780 0.011780\n", "ENPH 0.006759 0.006759 0.006759\n", "LLY 0.006514 0.006514 0.006514\n", "DXCM 0.004687 0.004687 0.004687\n", "EG 0.050000 -0.000000 0.000000\n", "RF 0.050000 -0.000000 0.000000\n", "WTW 0.050000 -0.000000 0.000000\n", "NVR 0.050000 0.000000 0.000000\n", "CL 0.050000 -0.000000 0.000000\n", "DUK 0.050000 -0.000000 0.000000\n", "CMA 0.050000 -0.000000 0.000000\n", "ED 0.050000 -0.000000 0.000000\n", "FRT 0.050000 -0.000000 0.000000\n", "STT 0.050000 -0.000000 0.000000\n", "AMCR 0.050000 -0.000000 0.000000\n", "APD 0.050000 -0.000000 0.000000\n", "ROP 0.050000 -0.000000 0.000000\n", "PSA 0.050000 -0.000000 0.000000\n", "V 0.050000 0.000000 0.000000\n", "KO 0.050000 -0.000000 0.000000\n", "GOOGL 0.050000 0.000000 0.000000\n", "AEE 0.050000 0.000000 0.000000" ] }, "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "print(f\"Expected return before/after: {mu @ x0:.6f}/{m.ObjVal:.6f}\")\n", "print(f\"Variance before/after: {x0 @ Sigma @ x0:.6f}/{x.X @ Sigma @ x.X:.6f}\")\n", "print(f\"Solution time: {m.Runtime:.2f} seconds\\n\")\n", "\n", "print(\n", " f\"Number of positions: before {np.count_nonzero(x0[x0>1e-5])}, after {np.count_nonzero(x.X[x.X>1e-5])}\"\n", ")\n", "print(f\"Number of buys: {np.count_nonzero(x_plus.X[x_plus.X>1e-5])}\")\n", "print(f\"Number of sells: {np.count_nonzero(x_minus.X[x_minus.X>1e-5])}\")\n", "print(f\"Total turnover: {np.abs(x.X - x0).sum():.6f}\\n\")\n", "\n", "# Print all assets with either non-negligible position or transaction\n", "df = pd.DataFrame(\n", " index=mu.index,\n", " data={\n", " \"position\": x.X,\n", " \"transaction\": x.X - x0,\n", " \"turnover\": np.abs(x.X - x0),\n", " },\n", ").round(6)\n", "df[(df[\"position\"] > 1e-5) | (abs(df[\"transaction\"]) > 1e-5)].sort_values(\n", " \"turnover\", ascending=False\n", ")" ] }, { "cell_type": "markdown", "id": "da441384", "metadata": {}, "source": [ "From the solution statistics, we see that with 20% turnover we could significantly increase the expected return while only slightly increasing the risk." ] }, { "cell_type": "markdown", "id": "02410326", "metadata": {}, "source": [ "## Takeaways\n", "* A turnover constraint can be modeled using additional continuous decision variables for the amounts bought and sold.\n", "* 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." ] }, { "cell_type": "code", "execution_count": null, "id": "9f359fa0", "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.11" } }, "nbformat": 4, "nbformat_minor": 5 }