{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "# Implementing the Critical Line Algorithm"
      ],
      "id": "90115206-ece2-49c4-b48d-f8ac90fb4d5c"
    },
    {
      "cell_type": "raw",
      "metadata": {
        "raw_mimetype": "tex"
      },
      "source": [
        "\\renewcommand{\\perp}{\\mathrel\\bot}\n",
        "\\newcommand{\\ev}{\\operatorname{E}}\n",
        "\\newcommand{\\var}{\\operatorname{V}}\n",
        "\\newcommand{\\cov}{\\operatorname{Cov}}\n",
        "\\newcommand{\\Normal}{\\mathcal{N}}\n",
        "\\newcommand{\\qedf}{\\qed{\\parfillskip=0pt \\par}}\n",
        "\\renewcommand{\\vec}[1]{\\mathbf{#1}}\n",
        "\\newcommand{\\gvec}[1]{\\pmb{#1}}\n",
        "\\newcommand{\\inner}[1]{\\langle{#1}\\rangle}\n",
        "\\newcommand{\\norm}[1]{\\lVert{#1}\\rVert}\n",
        "\\newcommand{\\prob}{\\operatorname{P}}\n",
        "\\newcommand{\\1}[1]{1\\kern-0.25em\\text{l}_{\\{#1\\}}}"
      ],
      "id": "ef9ca2cb-9701-4bfc-9cbc-ce409e7f9121"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Introduction\n",
        "\n",
        "The objective of this notebook is to compute the mean-variance frontier\n",
        "with short-sales constraints. Here I do so using the **Critical Line\n",
        "Algorithm** (CLA) of Markowitz (1959), although a simpler computational\n",
        "approach would be to solve the portfolio problem numerically on a fine\n",
        "grid of target returns.\n",
        "\n",
        "The value of the CLA is that it makes the structure of the constrained\n",
        "frontier explicit. With no short sales, the frontier is not described by\n",
        "one global formula. Instead, it is built from a sequence of segments,\n",
        "each corresponding to a fixed active set of assets with positive\n",
        "weights. On each such segment, the portfolio weights and KKT multipliers\n",
        "are affine functions of the target return $\\mu$. The frontier changes\n",
        "only at **corner portfolios**, where the active set changes: an asset\n",
        "leaves when its weight falls to zero, and an excluded asset enters when\n",
        "its shadow price falls to zero.\n",
        "\n",
        "The supporting theory is developed in the [Short-Sales\n",
        "Constraints](../portfolio-frontier-mathematics/portfolio-math-short-sales.qmd)\n",
        "notebook. The purpose of the code here is to show that theory\n",
        "numerically and make concrete the asset pricing results that the\n",
        "companion notebook develops.\n",
        "\n",
        "### The constrained problem\n",
        "\n",
        "We solve the standard mean-variance problem with non-negativity\n",
        "constraints: $$\n",
        "\\min_{\\mathbf{w}} \\;\\tfrac{1}{2}\\,\\mathbf{w}'\\mathbf{V}\\mathbf{w}\n",
        "\\quad\\text{s.t.}\\quad\n",
        "\\mathbf{w}'\\mathbf{e} = \\mu,\\;\n",
        "\\mathbf{w}'\\pmb{\\iota} = 1,\\;\n",
        "w_i \\ge 0\\;\\;\\forall\\,i,\n",
        "$$ where $\\mathbf{V}$ is the $n\\times n$ positive-definite covariance\n",
        "matrix, $\\mathbf{e}$ the vector of expected returns, and $\\pmb{\\iota}$ a\n",
        "vector of ones. The Lagrangian introduces three types of multipliers: $$\n",
        "\\mathcal{L}\n",
        "= \\tfrac{1}{2}\\,\\mathbf{w}'\\mathbf{V}\\mathbf{w}\n",
        "  - \\lambda\\left(\\mathbf{w}'\\mathbf{e} - \\mu\\right)\n",
        "  - \\gamma\\left(\\mathbf{w}'\\pmb{\\iota} - 1\\right)\n",
        "  - \\pmb{\\nu}'\\mathbf{w},\n",
        "$$ where $\\lambda$ enforces the return target, $\\gamma$ enforces the\n",
        "budget constraint, and $\\nu_i \\ge 0$ enforces non-negativity on each\n",
        "weight. Because $\\mathbf{V}$ is positive definite, the KKT first-order\n",
        "conditions are necessary and sufficient: $$\n",
        "\\mathbf{V}\\mathbf{w}\n",
        "= \\lambda\\,\\mathbf{e} + \\gamma\\,\\pmb{\\iota} + \\pmb{\\nu},\n",
        "\\qquad\n",
        "\\mathbf{w}'\\pmb{\\iota} = 1,\n",
        "\\qquad\n",
        "\\mathbf{w}'\\mathbf{e} = \\mu,\n",
        "$$ together with the complementary-slackness conditions $\\nu_i \\ge 0$,\n",
        "$w_i \\ge 0$, and $\\nu_i\\,w_i = 0$ for every asset $i$.\n",
        "\n",
        "### Active set, reduced system, and corners\n",
        "\n",
        "Complementary slackness splits the assets into two groups. The **active\n",
        "set** $$\n",
        "S = \\{i : w_i > 0\\}\n",
        "$$ contains the assets that are currently held. For these assets the\n",
        "non-negativity constraint is slack, so $\\nu_i = 0$. The **inactive set**\n",
        "$$\n",
        "S^c = \\{i : w_i = 0\\}\n",
        "$$ contains the excluded assets. For them, $\\nu_j \\ge 0$ is the shadow\n",
        "value of the no-short-sales constraint: if $\\nu_j > 0$, forcing\n",
        "$w_j = 0$ is still optimal; if $\\nu_j = 0$, asset $j$ is exactly at the\n",
        "point where it may enter.\n",
        "\n",
        "Once $S$ is fixed, the constrained problem reduces to an ordinary\n",
        "Markowitz problem on the active assets only. Restricting the\n",
        "stationarity condition to $i \\in S$ and using $\\nu_i = 0$ gives $$\n",
        "\\mathbf{V}_S\\,\\mathbf{w}_S = \\lambda\\,\\mathbf{e}_S + \\gamma\\,\\pmb{\\iota}_S,\n",
        "$$ and the return and budget constraints become $$\n",
        "\\mathbf{w}_S'\\mathbf{e}_S = \\mu,\n",
        "\\qquad\n",
        "\\mathbf{w}_S'\\pmb{\\iota}_S = 1.\n",
        "$$ Solving the first equation for the active weights gives $$\n",
        "\\mathbf{w}_S\n",
        "=\n",
        "\\lambda\\,\\mathbf{V}_S^{-1}\\mathbf{e}_S\n",
        "+\n",
        "\\gamma\\,\\mathbf{V}_S^{-1}\\pmb{\\iota}_S.\n",
        "$$ Substituting this expression into the return and budget constraints\n",
        "leaves a $2\\times 2$ linear system in the multipliers $\\lambda$ and\n",
        "$\\gamma$. To write that system compactly, define $$\n",
        "A_S = \\pmb{\\iota}_S'\\mathbf{V}_S^{-1}\\mathbf{e}_S,\\quad\n",
        "B_S = \\mathbf{e}_S'\\mathbf{V}_S^{-1}\\mathbf{e}_S,\\quad\n",
        "C_S = \\pmb{\\iota}_S'\\mathbf{V}_S^{-1}\\pmb{\\iota}_S,\\quad\n",
        "D_S = B_S C_S - A_S^2.\n",
        "$$ Then $$\n",
        "\\mu = \\lambda B_S + \\gamma A_S,\n",
        "\\qquad\n",
        "1 = \\lambda A_S + \\gamma C_S.\n",
        "$$ Solving for the multipliers yields $$\n",
        "\\lambda(\\mu) = \\frac{C\\mu - A}{D},\n",
        "\\qquad\n",
        "\\gamma(\\mu) = \\frac{B - A\\mu}{D},\n",
        "\\qquad D > 0.\n",
        "$$ Substituting back into $\\mathbf{w}_S$ shows that each active weight\n",
        "is affine in target return: $$\n",
        "w_i(\\mu) = a_{w,i} + b_{w,i}\\,\\mu,\\quad i \\in S.\n",
        "$$ Thus, once the active set is fixed, the whole segment is described by\n",
        "affine functions of $\\mu$.\n",
        "\n",
        "For an inactive asset $j \\in S^c$, the multiplier is $$\n",
        "\\nu_j(\\mu) = (\\mathbf{V}\\mathbf{w})_j - \\lambda(\\mu)e_j - \\gamma(\\mu),\n",
        "$$ which is also affine in $\\mu$ because $\\mathbf{w}(\\mu)$,\n",
        "$\\lambda(\\mu)$, and $\\gamma(\\mu)$ are affine. This is the key\n",
        "simplification behind the CLA: every candidate exit is the zero of an\n",
        "active-weight line, and every candidate entry is the zero of an\n",
        "inactive-multiplier line.\n",
        "\n",
        "As $\\mu$ varies, the active set stays fixed over an interval, so the\n",
        "efficient portfolios on that interval lie on a single frontier segment.\n",
        "A **corner portfolio** is the endpoint of such a segment, where one of\n",
        "those affine entry or exit conditions hits zero and the active set\n",
        "changes. The constrained frontier is therefore piecewise, with one\n",
        "segment for each active set and corners joining adjacent segments.\n",
        "\n",
        "### Algorithm outline\n",
        "\n",
        "The CLA then sweeps from the highest feasible return downward, one\n",
        "corner at a time:\n",
        "\n",
        "1.  **Seed**: start at the maximum-return corner (100 % in the\n",
        "    highest-mean asset) and find a valid two-asset active set.\n",
        "2.  **Build segment**: solve the reduced KKT system for the current $S$\n",
        "    to obtain the affine weight and multiplier formulas.\n",
        "3.  **Scan for events**: compute all candidate entry and exit returns\n",
        "    below the current return.\n",
        "4.  **Select next corner**: the largest such crossing is the next corner\n",
        "    $\\mu^*$.\n",
        "5.  **Update $S$**: remove assets whose weights hit zero and add assets\n",
        "    whose shadow prices hit zero.\n",
        "6.  **Repeat** until no crossings remain or $S$ becomes empty.\n",
        "\n",
        "The implementation below translates each of these steps into a short,\n",
        "self-contained code cell.\n",
        "\n",
        "## Core Code\n",
        "\n",
        "The code follows the derivation above: build the affine formulas on a\n",
        "fixed active set, compute entry and exit returns, and update the active\n",
        "set from corner to corner.\n",
        "\n",
        "### Imports and numerical tolerances\n",
        "\n",
        "These imports and tolerances are used throughout. The tolerances control\n",
        "degeneracy checks, affine roots, progress from one corner to the next,\n",
        "and small feasibility errors."
      ],
      "id": "a44a967b-a123-4c00-97e7-831282ae9e92"
    },
    {
      "cell_type": "code",
      "execution_count": 2,
      "metadata": {},
      "outputs": [],
      "source": [
        "import numpy as np\n",
        "import matplotlib.pyplot as plt\n",
        "import yfinance as yf\n",
        "import pandas as pd\n",
        "from typing import NamedTuple\n",
        "\n",
        "EPS_DET = 1e-14\n",
        "EPS_SLOPE = 1e-14\n",
        "EPS_EVENT = 1e-10\n",
        "EPS_ACTIVE = 1e-8"
      ],
      "id": "5a3e52fb"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### Data structure for one CLA segment\n",
        "\n",
        "For a fixed active set, weights and KKT multipliers are affine in $\\mu$.\n",
        "`Segment` stores those coefficients for one frontier segment."
      ],
      "id": "9399959b-012b-48f7-9c3d-5be34c590483"
    },
    {
      "cell_type": "code",
      "execution_count": 3,
      "metadata": {},
      "outputs": [],
      "source": [
        "class Segment(NamedTuple):\n",
        "    aw: np.ndarray\n",
        "    bw: np.ndarray\n",
        "    lam_a: float\n",
        "    lam_b: float\n",
        "    gam_a: float\n",
        "    gam_b: float"
      ],
      "id": "22894811"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### Basic helpers\n",
        "\n",
        "These are utility functions for evaluating segment weights, computing\n",
        "volatility, and loading annualized inputs."
      ],
      "id": "63160d0e-02dc-4eb8-8d6c-a7d1e7851e8a"
    },
    {
      "cell_type": "code",
      "execution_count": 4,
      "metadata": {},
      "outputs": [],
      "source": [
        "def weights_at_mu(seg, mu):\n",
        "    return seg.aw + seg.bw * mu\n",
        "\n",
        "def sigma_of_weights(w, V):\n",
        "    return np.sqrt(max(w @ V @ w, 0.0))\n",
        "\n",
        "def load_annualized_inputs(tickers, start_date):\n",
        "    prices = yf.download(\n",
        "        tickers,\n",
        "        start=start_date,\n",
        "        interval='1mo',\n",
        "        auto_adjust=True,\n",
        "        progress=False,\n",
        "    )['Close']\n",
        "    ret = prices.pct_change().dropna()\n",
        "    e = 12 * ret.mean().to_numpy()\n",
        "    V = 12 * ret.cov().to_numpy()\n",
        "    return e, V"
      ],
      "id": "260f48c3"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### Frontier scalars on a fixed active set\n",
        "\n",
        "Given an active set $S$, the Markowitz scalars\n",
        "$$A=\\pmb{\\iota}_S'\\mathbf{V}_S^{-1}\\mathbf{e}_S,\\; B=\\mathbf{e}_S'\\mathbf{V}_S^{-1}\\mathbf{e}_S,\\; C=\\pmb{\\iota}_S'\\mathbf{V}_S^{-1}\\pmb{\\iota}_S$$\n",
        "summarize the reduced problem. This helper computes them from a single\n",
        "linear solve."
      ],
      "id": "01f779ef-8dfb-4483-99bc-870e4bcb465b"
    },
    {
      "cell_type": "code",
      "execution_count": 5,
      "metadata": {},
      "outputs": [],
      "source": [
        "def frontier_scalars(Vs, es):\n",
        "    ones = np.ones(len(es))\n",
        "    X = np.linalg.solve(Vs, np.column_stack([es, ones]))\n",
        "    x_e, x_1 = X[:, 0], X[:, 1]\n",
        "    A = ones @ x_e\n",
        "    B = es @ x_e\n",
        "    C = ones @ x_1\n",
        "    return A, B, C"
      ],
      "id": "91308548"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### Build one segment\n",
        "\n",
        "This is the fixed-active-set solve. It computes the affine formulas for\n",
        "$\\lambda(\\mu)$, $\\gamma(\\mu)$, and $w(\\mu)$ on the current segment."
      ],
      "id": "400d35d7-6ce0-4e14-879f-230b2e76ac15"
    },
    {
      "cell_type": "code",
      "execution_count": 6,
      "metadata": {},
      "outputs": [],
      "source": [
        "def build_segment(active, e, V, n):\n",
        "    Vs = V[np.ix_(active, active)]\n",
        "    es = e[active]\n",
        "    ones = np.ones(len(active))\n",
        "\n",
        "    X = np.linalg.solve(Vs, np.column_stack([es, ones]))\n",
        "    x_e, x_1 = X[:, 0], X[:, 1]\n",
        "    A = ones @ x_e\n",
        "    B = es @ x_e\n",
        "    C = ones @ x_1\n",
        "    D = B * C - A**2\n",
        "    if D < EPS_DET:\n",
        "        return None\n",
        "\n",
        "    lam_a, lam_b = -A / D, C / D\n",
        "    gam_a, gam_b = B / D, -A / D\n",
        "\n",
        "    aw = np.zeros(n)\n",
        "    bw = np.zeros(n)\n",
        "    aw[active] = lam_a * x_e + gam_a * x_1\n",
        "    bw[active] = lam_b * x_e + gam_b * x_1\n",
        "    return Segment(aw, bw, lam_a, lam_b, gam_a, gam_b)"
      ],
      "id": "5d29911a"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### Event equations: when does an asset hit the boundary?\n",
        "\n",
        "Each candidate corner comes from the zero of an affine function. Active\n",
        "assets exit when $w_i(\\mu)=0$; inactive assets enter when\n",
        "$\\nu_j(\\mu)=0$."
      ],
      "id": "56c23a7d-cf98-49b3-a7d1-01e6a71e0736"
    },
    {
      "cell_type": "code",
      "execution_count": 7,
      "metadata": {},
      "outputs": [],
      "source": [
        "def _affine_root(a, b):\n",
        "    \"\"\"Return -a/b if |b| is large enough, else None.\"\"\"\n",
        "    if abs(b) < EPS_SLOPE:\n",
        "        return None\n",
        "    return -a / b\n",
        "\n",
        "def w_zero(i, seg):\n",
        "    return _affine_root(seg.aw[i], seg.bw[i])\n",
        "\n",
        "def nu_zero(j, seg, e, V):\n",
        "    av = V[j] @ seg.aw - seg.lam_a * e[j] - seg.gam_a\n",
        "    bv = V[j] @ seg.bw - seg.lam_b * e[j] - seg.gam_b\n",
        "    return _affine_root(av, bv)"
      ],
      "id": "d85adf98"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### Seed segment at the top corner\n",
        "\n",
        "The algorithm starts at the maximum-return corner. This routine looks\n",
        "for a valid two-asset seed segment consistent with that corner and the\n",
        "dual feasibility conditions."
      ],
      "id": "c0f339bc-0490-4468-846e-508cefc91b61"
    },
    {
      "cell_type": "code",
      "execution_count": 8,
      "metadata": {},
      "outputs": [],
      "source": [
        "def find_valid_seed(max_return_idx, e, V, n):\n",
        "    top_mu = e[max_return_idx]\n",
        "    for candidate in range(n):\n",
        "        if candidate == max_return_idx:\n",
        "            continue\n",
        "\n",
        "        active = sorted([max_return_idx, candidate])\n",
        "        seg = build_segment(active, e, V, n)\n",
        "        if seg is None:\n",
        "            continue\n",
        "\n",
        "        w_top = weights_at_mu(seg, top_mu)\n",
        "        if (\n",
        "            abs(w_top[candidate]) > EPS_ACTIVE\n",
        "            or abs(w_top[max_return_idx] - 1.0) > EPS_ACTIVE\n",
        "        ):\n",
        "            continue\n",
        "\n",
        "        lam_top = seg.lam_a + seg.lam_b * top_mu\n",
        "        gam_top = seg.gam_a + seg.gam_b * top_mu\n",
        "        if all(\n",
        "            V[idx] @ w_top - lam_top * e[idx] - gam_top >= -EPS_ACTIVE\n",
        "            for idx in range(n)\n",
        "            if idx not in active\n",
        "        ):\n",
        "            return active, seg\n",
        "\n",
        "    return None, None"
      ],
      "id": "fd0aeb02"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### Collect entry/exit candidates from current segment\n",
        "\n",
        "Given a segment, this function computes all admissible exit and entry\n",
        "returns below the current corner."
      ],
      "id": "c876eb20-1731-4b75-9f82-edd31688ca7f"
    },
    {
      "cell_type": "code",
      "execution_count": 9,
      "metadata": {},
      "outputs": [],
      "source": [
        "def find_events(active, n, seg, mu_current, e, V):\n",
        "    active_set = set(active)\n",
        "    events = [\n",
        "        (mu_event, idx, 'exit')\n",
        "        for idx in active\n",
        "        if (mu_event := w_zero(idx, seg)) is not None\n",
        "        and mu_event < mu_current - EPS_EVENT\n",
        "    ]\n",
        "    events += [\n",
        "        (mu_event, idx, 'enter')\n",
        "        for idx in range(n)\n",
        "        if idx not in active_set\n",
        "        and (mu_event := nu_zero(idx, seg, e, V)) is not None\n",
        "        and mu_event < mu_current - EPS_EVENT\n",
        "    ]\n",
        "    return events"
      ],
      "id": "10229538"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### Main CLA loop\n",
        "\n",
        "This is the main CLA loop. It builds a segment, finds the next corner,\n",
        "updates the active set, and repeats until no further corner is found."
      ],
      "id": "391d2f96-62a5-4f69-b829-2b822dd5e3c8"
    },
    {
      "cell_type": "code",
      "execution_count": 10,
      "metadata": {},
      "outputs": [],
      "source": [
        "def run_cla(e, V):\n",
        "    n = len(e)\n",
        "    max_return_idx = int(np.argmax(e))\n",
        "\n",
        "    corners = [(e[max_return_idx], np.eye(n)[max_return_idx])]\n",
        "    segments = []\n",
        "\n",
        "    active, seed_seg = find_valid_seed(max_return_idx, e, V, n)\n",
        "    if active is None:\n",
        "        active = list(range(n))\n",
        "\n",
        "    mu_current = e[max_return_idx]\n",
        "    seg = seed_seg\n",
        "\n",
        "    for _ in range(2 * n + 4):\n",
        "        if seg is None:\n",
        "            seg = build_segment(active, e, V, n)\n",
        "        if seg is None:\n",
        "            break\n",
        "\n",
        "        candidates = find_events(active, n, seg, mu_current, e, V)\n",
        "        if not candidates:\n",
        "            break\n",
        "\n",
        "        mu_next = max(candidates, key=lambda x: x[0])[0]\n",
        "        events_at_corner = [evt for evt in candidates if abs(evt[0] - mu_next) <= EPS_EVENT]\n",
        "        segments.append((seg, mu_current, mu_next))\n",
        "\n",
        "        w_corner = np.clip(weights_at_mu(seg, mu_next), 0, None)\n",
        "        weight_sum = w_corner.sum()\n",
        "        if weight_sum <= EPS_ACTIVE:\n",
        "            break\n",
        "        w_corner /= weight_sum\n",
        "        corners.append((mu_next, w_corner))\n",
        "\n",
        "        exits = {asset for _, asset, evt in events_at_corner if evt == 'exit'}\n",
        "        enters = {asset for _, asset, evt in events_at_corner if evt == 'enter'}\n",
        "        active = sorted((set(active) - exits) | enters)\n",
        "\n",
        "        mu_current = mu_next\n",
        "        seg = None\n",
        "        if not active:\n",
        "            break\n",
        "\n",
        "    return corners, segments"
      ],
      "id": "2dbae6e6"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### Sample points on the constrained and unconstrained frontiers\n",
        "\n",
        "The CLA returns corners and affine segment formulas. These helpers\n",
        "convert them into plot-ready points and compute the unconstrained\n",
        "benchmark frontier."
      ],
      "id": "7ae9112a-ff2b-4d5a-bf07-f402ad3264fd"
    },
    {
      "cell_type": "code",
      "execution_count": 11,
      "metadata": {},
      "outputs": [],
      "source": [
        "def evaluate_constrained_frontier(segments, V, grid_points):\n",
        "    mu_con, sig_con = [], []\n",
        "    for seg, mu_high, mu_low in segments:\n",
        "        for mu_val in np.linspace(mu_high, mu_low, grid_points):\n",
        "            w = weights_at_mu(seg, mu_val)\n",
        "            if np.all(w >= -EPS_ACTIVE):\n",
        "                mu_con.append(mu_val)\n",
        "                sig_con.append(sigma_of_weights(w, V))\n",
        "    return np.array(mu_con), np.array(sig_con)\n",
        "\n",
        "def unconstrained_frontier(e, V):\n",
        "    A, B, C = frontier_scalars(V, e)\n",
        "    D = B * C - A**2\n",
        "    if D < EPS_DET:\n",
        "        raise ValueError('Degenerate unconstrained frontier: D is too small.')\n",
        "    mu_unc = np.linspace(A / C - 0.08, e.max(), 600)\n",
        "    var_unc = (B - 2 * A * mu_unc + C * mu_unc**2) / D\n",
        "    sig_unc = np.sqrt(np.maximum(var_unc, 0.0))\n",
        "    return mu_unc, sig_unc"
      ],
      "id": "58e9dce9"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### Plotting helpers\n",
        "\n",
        "These functions plot the constrained and unconstrained frontiers and\n",
        "optionally label the corner portfolios."
      ],
      "id": "001fbf98-d0a1-44e5-b4f3-fa84e4edcc24"
    },
    {
      "cell_type": "code",
      "execution_count": 12,
      "metadata": {},
      "outputs": [],
      "source": [
        "def corner_weight_label(w, tickers):\n",
        "    parts = [f\"{tk}:{wi:.2f}\" for tk, wi in zip(tickers, w) if wi > 1e-3]\n",
        "    return '\\n'.join(parts)\n",
        "\n",
        "def plot_frontiers(e, V, corners, segments, tickers, title, label_corner_weights=False, grid_points=300):\n",
        "    mu_con, sig_con = evaluate_constrained_frontier(segments, V, grid_points=grid_points)\n",
        "    mu_unc, sig_unc = unconstrained_frontier(e, V)\n",
        "\n",
        "    fig, ax = plt.subplots(figsize=(7, 5))\n",
        "    ax.plot(sig_unc * 100, mu_unc * 100, color='steelblue', lw=2, ls='--', label='Unconstrained')\n",
        "    ax.plot(sig_con * 100, mu_con * 100, color='darkorange', lw=2, label=r'Constrained ($w_i \\geq 0$)')\n",
        "\n",
        "    n_corners = max(len(corners), 1)\n",
        "    for idx, (mu_c, w_c) in enumerate(corners):\n",
        "        sc = sigma_of_weights(w_c, V)\n",
        "        ax.scatter([sc * 100], [mu_c * 100], color='darkorange', zorder=5, s=48)\n",
        "        if label_corner_weights:\n",
        "            angle = 2 * np.pi * idx / n_corners\n",
        "            radius = 16\n",
        "            dx = int(np.round(radius * np.cos(angle)))\n",
        "            dy = int(np.round(radius * np.sin(angle)))\n",
        "            ha = 'left' if dx >= 0 else 'right'\n",
        "            va = 'bottom' if dy >= 0 else 'top'\n",
        "            ax.annotate(\n",
        "                corner_weight_label(w_c, tickers),\n",
        "                (sc * 100, mu_c * 100),\n",
        "                textcoords='offset points',\n",
        "                xytext=(dx, dy),\n",
        "                ha=ha,\n",
        "                va=va,\n",
        "                fontsize=8,\n",
        "                bbox=dict(boxstyle='round,pad=0.2', fc='white', ec='0.8', alpha=0.95),\n",
        "                arrowprops=dict(arrowstyle='-', color='0.55', lw=0.8, shrinkA=4, shrinkB=4),\n",
        "            )\n",
        "\n",
        "    ax.set_title(title)\n",
        "    ax.set_xlabel(r'$\\sigma$ (%)')\n",
        "    ax.set_ylabel(r'$\\mu$ (%)')\n",
        "    ax.set_xlim(left=0)\n",
        "    ax.legend(frameon=False, fontsize=9)\n",
        "    ax.spines[['top', 'right']].set_visible(False)\n",
        "    plt.tight_layout()\n",
        "    plt.show()"
      ],
      "id": "8cf21021"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### Tabular summary of corner portfolios\n",
        "\n",
        "This helper builds a table with return, risk, and weights for each\n",
        "corner portfolio."
      ],
      "id": "ed29e6d6-4991-4e61-b97a-317ed59d23d8"
    },
    {
      "cell_type": "code",
      "execution_count": 13,
      "metadata": {},
      "outputs": [],
      "source": [
        "def build_corner_weights_table(corners, tickers, V):\n",
        "    rows = []\n",
        "    for idx, (mu_c, w_c) in enumerate(corners):\n",
        "        rows.append(\n",
        "            {\n",
        "                'Corner': f'C{idx + 1}',\n",
        "                'μ (%)': f'{mu_c * 100:.2f}',\n",
        "                'σ (%)': f'{sigma_of_weights(w_c, V) * 100:.2f}',\n",
        "                **{tk: f'{w_c[i]:.4f}' for i, tk in enumerate(tickers)},\n",
        "            }\n",
        "        )\n",
        "    return pd.DataFrame(rows).set_index('Corner')"
      ],
      "id": "f1702800"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Examples\n",
        "\n",
        "These examples apply the same workflow to two asset universes. The\n",
        "four-asset case makes the corner portfolios easy to inspect; the\n",
        "ten-asset case shows the same algorithm on a larger universe. In each\n",
        "figure, compare the piecewise constrained frontier to the smooth\n",
        "unconstrained benchmark and relate each bend to a change in the active\n",
        "set.\n",
        "\n",
        "### An Example with Four Assets\n",
        "\n",
        "With four assets, the corner labels remain readable. This makes it easy\n",
        "to see how the active set changes from one corner to the next."
      ],
      "id": "3dd2274e-6ba2-49a9-9e65-db35cab160f7"
    },
    {
      "cell_type": "code",
      "execution_count": 14,
      "metadata": {},
      "outputs": [
        {
          "output_type": "display_data",
          "metadata": {},
          "data": {}
        }
      ],
      "source": [
        "tickers_4 = ['AAPL', 'BA', 'C', 'WMT']\n",
        "start_date = '2000-01-01'\n",
        "grid_points = 300\n",
        "\n",
        "e_4, V_4 = load_annualized_inputs(tickers_4, start_date=start_date)\n",
        "corners_4, segments_4 = run_cla(e_4, V_4)\n",
        "\n",
        "plot_frontiers(\n",
        "    e_4,\n",
        "    V_4,\n",
        "    corners_4,\n",
        "    segments_4,\n",
        "    tickers_4,\n",
        "    title='CLA Frontier: AAPL, BA, C, WMT',\n",
        "    label_corner_weights=True,\n",
        "    grid_points=grid_points,\n",
        ")"
      ],
      "id": "cell-fig-cla-4assets"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "The table below lists the corner weights explicitly. Reading adjacent\n",
        "rows shows how portfolio composition changes from one corner to the\n",
        "next."
      ],
      "id": "55956055-d8f6-4a9d-9455-53138ea4b19d"
    },
    {
      "cell_type": "code",
      "execution_count": 15,
      "metadata": {},
      "outputs": [],
      "source": [
        "build_corner_weights_table(corners_4, tickers_4, V_4)"
      ],
      "id": "06aa4bad-b1fb-40c3-a70c-12b61c626872"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### A More Realistic Universe\n",
        "\n",
        "This example uses the same code on a ten-asset universe. Labels are\n",
        "omitted to keep the figure readable, but the logic is unchanged: each\n",
        "kink corresponds to a change in the active set."
      ],
      "id": "61b60353-2fd5-407c-be1f-5097afa7ce3d"
    },
    {
      "cell_type": "code",
      "execution_count": 16,
      "metadata": {},
      "outputs": [
        {
          "output_type": "display_data",
          "metadata": {},
          "data": {}
        }
      ],
      "source": [
        "tickers_10 = ['AAPL', 'BA', 'C', 'GE', 'GM', 'JNJ', 'KO', 'MAR', 'MMM', 'WMT']\n",
        "e_10, V_10 = load_annualized_inputs(tickers_10, start_date=start_date)\n",
        "corners_10, segments_10 = run_cla(e_10, V_10)\n",
        "\n",
        "plot_frontiers(\n",
        "    e_10,\n",
        "    V_10,\n",
        "    corners_10,\n",
        "    segments_10,\n",
        "    tickers_10,\n",
        "    title='CLA Frontier: 10 Assets',\n",
        "    label_corner_weights=False,\n",
        "    grid_points=grid_points,\n",
        ")"
      ],
      "id": "cell-fig-cla-10assets"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "### CLA vs. Generic Solver\n",
        "\n",
        "The last example compares the CLA with a generic quadratic-program\n",
        "solver. The CVXPY solution traces the frontier point by point, while the\n",
        "CLA builds it segment by segment from the entry and exit conditions. The\n",
        "two curves should coincide."
      ],
      "id": "8f31c91a-f735-4bad-9dc8-c105dc48a156"
    },
    {
      "cell_type": "code",
      "execution_count": 17,
      "metadata": {},
      "outputs": [
        {
          "output_type": "display_data",
          "metadata": {},
          "data": {}
        }
      ],
      "source": [
        "import warnings\n",
        "import cvxpy as cp\n",
        "\n",
        "n = len(e_10)\n",
        "w_var = cp.Variable(n)\n",
        "mu_target = cp.Parameter()\n",
        "prob = cp.Problem(\n",
        "    cp.Minimize(cp.quad_form(w_var, V_10)),\n",
        "    [w_var @ e_10 == mu_target, cp.sum(w_var) == 1, w_var >= 0],\n",
        ")\n",
        "\n",
        "mu_grid = np.linspace(e_10.min(), e_10.max(), 300)\n",
        "mu_cvx, sig_cvx = [], []\n",
        "for mu_val in mu_grid:\n",
        "    mu_target.value = mu_val\n",
        "    with warnings.catch_warnings():\n",
        "        warnings.simplefilter('ignore', UserWarning)\n",
        "        prob.solve()\n",
        "    if prob.status == 'optimal':\n",
        "        mu_cvx.append(mu_val)\n",
        "        sig_cvx.append(np.sqrt(w_var.value @ V_10 @ w_var.value))\n",
        "\n",
        "mu_cla, sig_cla = evaluate_constrained_frontier(segments_10, V_10, grid_points=grid_points)\n",
        "\n",
        "fig, ax = plt.subplots(figsize=(7, 5))\n",
        "ax.plot(sig_cla * 100, mu_cla * 100, color='darkorange', lw=2, label='CLA')\n",
        "ax.scatter(\n",
        "    np.array(sig_cvx) * 100,\n",
        "    np.array(mu_cvx) * 100,\n",
        "    color='steelblue', s=10, zorder=5, label='CVXPY',\n",
        ")\n",
        "ax.set_title('CLA vs. CVXPY: 10 Assets')\n",
        "ax.set_xlabel(r'$\\sigma$ (%)')\n",
        "ax.set_ylabel(r'$\\mu$ (%)')\n",
        "ax.set_xlim(left=0)\n",
        "ax.legend(frameon=False, fontsize=9)\n",
        "ax.spines[['top', 'right']].set_visible(False)\n",
        "plt.tight_layout()\n",
        "plt.show()"
      ],
      "id": "cell-fig-cla-vs-cvxpy"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "Markowitz, Harry M. 1959. *Portfolio Selection: Efficient\n",
        "Diversification of Investments*. Cowles Foundation Monograph 16. John\n",
        "Wiley & Sons."
      ],
      "id": "b14c6537-d783-4f1c-87a2-b4a3d6543b2e"
    }
  ],
  "nbformat": 4,
  "nbformat_minor": 5,
  "metadata": {
    "kernelspec": {
      "name": "python3",
      "display_name": "Python 3 (ipykernel)",
      "language": "python",
      "path": "/home/lnaranjo/.pyenv/versions/3.14.7/share/jupyter/kernels/python3"
    }
  }
}