---
title: "0.7.3 Intuitive linear algebra for portfolios"
description: "Weight vectors, return matrices and the covariance matrix: portfolio volatility, equal weights versus 1/σ, and risk contributions."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/linear-algebra-for-portfolios/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/linear-algebra-for-portfolios.png"
---

# 0.7.3 · Intuitive linear algebra: vectors and matrices as groundwork for portfolios

> A portfolio is a set of weights; a market day, a set of returns; five years, a table. With three linear algebra operations (dot product, matrix product and quadratic form) you can compute the return and risk of any portfolio, and see why diversifying reduces risk and why splitting capital equally does not split risk equally.

- Course: [Level 0: Python from scratch and a data mindset](https://pythonparatrading.com/en/python-trading-course/level-0/)
- Module: 0.7 · Minimum mathematics and statistics
- Duration: 50 min
- Prerequisites: [0.7.2 · Probability and distributions, correlation versus causation and simple regression](https://pythonparatrading.com/en/python-trading-course/level-0/probability-distributions-and-regression/), [0.5.1 · NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators](https://pythonparatrading.com/en/python-trading-course/level-0/numpy-arrays-and-vectorization/)

## By the end of this lesson

- Represent a portfolio as a weight vector and compute its return with the dot product `w @ r`.
- Read the `shape` of returns, weights and covariances, and predict which matrix products are valid.
- Build the covariance matrix from volatilities and correlations and compute a portfolio's volatility with w′Σw.
- Compare an equal-weight portfolio with an inverse-volatility one by their volatility and by each asset's contribution to risk.

## A portfolio is a vector

Lesson 0.5.1 used `@` and `np.cov` to compute a portfolio's volatility. This lesson explains why those calculations
work, with as little linear algebra as possible, because it is the language in which every portfolio construction
method in the course is written.

A **vector** is an ordered list of numbers. A four-asset portfolio is a vector of **weights**: the fraction of capital
in each one. The returns of the four assets on a given day are another vector, in the same order:

$$
w = \begin{pmatrix} w_1 \\ w_2 \\ w_3 \\ w_4 \end{pmatrix} \qquad
r = \begin{pmatrix} r_1 \\ r_2 \\ r_3 \\ r_4 \end{pmatrix} \qquad
w \cdot r = \sum_{i=1}^{4} w_i\, r_i
$$

The **dot product** $w \cdot r$ multiplies element by element and adds. It is exactly the portfolio's simple return
that day (lesson 0.7.1: across assets, the portfolio's simple return is the weighted mean of the simple returns). With
the course's four fictional assets and a 40/30/10/20 portfolio, on RVAR's worst day:

```python
import numpy as np
import pandas as pd

cartera = pd.read_csv("cartera_sint.csv", parse_dates=["fecha"],
                      index_col="fecha")
rc = cartera.pct_change().dropna()
activos = list(rc.columns)
w = np.array([0.40, 0.30, 0.10, 0.20])
dia = rc["RVAR"].idxmin()
r_dia = rc.loc[dia].to_numpy()
print(f"{dia:%Y-%m-%d} {activos}")
print(f"Returns: {r_dia.round(4)}")
bucle = sum(wi * ri for wi, ri in zip(w, r_dia))
print(f"Loop: {bucle:.4%} · w @ r: {w @ r_dia:.4%}")
```
```output
2025-01-23 ['RVAR', 'RFIJ', 'ORO', 'TECH']
Returns: [-0.0374  0.0004 -0.0071 -0.0459]
Loop: -2.4725% · w @ r: -2.4725%
```

The loop and `w @ r_dia` give the same result: `@` is the dot product when both operands are vectors. On the day RVAR
(equities) falls 3.74%, the portfolio loses 2.47%: the 30% in fixed income (RFIJ), which barely moves, and the 10% in
gold (ORO), which falls much less, cushion the fall; TECH, at −4.59%, makes it worse. That offsetting between assets
is, in a single day, the idea of diversification.

## From one day to the whole series: matrices and shapes

A **matrix** is a table of numbers with rows and columns. Five years of returns are a matrix $R$ with one row per day
and one column per asset. Multiplying the matrix by the weight vector takes the dot product of each row with the
weights: the portfolio's return **every day**, all at once and without loops.

```mermaid
flowchart LR
  R[R · 1302×4] --> M[R @ w]
  W[w · 4] --> M
  M --> S[portfolio · 1302]
```

The rule that decides whether a matrix product is valid is the **shape** (the `shape` attribute): in $A\,B$, the
number of columns of $A$ has to equal the number of rows of $B$, and that dimension disappears from the result.
$(1302 \times 4)$ times $(4)$ gives $(1302)$:

```python
R = rc.to_numpy()
serie = R @ w
print(f"R {R.shape} @ w {w.shape} = {serie.shape}")
print(f"First day: {serie[0]:.6f} = {w @ R[0]:.6f}")
print(f"Annual mean: {serie.mean() * 252:.2%} · "
      f"volatility: {serie.std(ddof=1) * np.sqrt(252):.2%}")
```
```output
R (1302, 4) @ w (4,) = (1302,)
First day: -0.005898 = -0.005898
Annual mean: 7.00% · volatility: 12.00%
```

A series with constant weights every day assumes the portfolio is **rebalanced daily** back to the initial weights.
Without rebalancing, the weights drift with prices: the asset that rises gains weight. It is a common approximation that
will be refined when the course reaches portfolio backtests.

With the order swapped, the inner dimensions no longer match and NumPy says so:

```python raises
w @ R
```
```output
ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 1302 is different from 4)
```

That error is a stroke of luck. The dangerous case is the opposite: a product that **does** work with the wrong
shapes. With four assets and four days, `R` would be $4 \times 4$ and `w @ R` would give a meaningless result without
complaint. Before multiplying, print the shapes and say out loud what each axis is (rows: days; columns: assets).

| Object | Shape | What it is |
|---|---|---|
| `w` | `(4,)` | Portfolio weights |
| `R` | `(1302, 4)` | Returns: days × assets |
| `R @ w` | `(1302,)` | Portfolio's daily return |
| `Sigma` | `(4, 4)` | Covariances between assets |
| `Sigma @ w` | `(4,)` | Each asset's covariance with the portfolio |
| `w @ Sigma @ w` | `()` | Portfolio variance, a single number |

## The covariance matrix, piece by piece

Lesson 0.5.1 computed the covariance matrix $\Sigma$ with `np.cov`. Its content is easier to understand by building it
from what you already know: each asset's volatility and the correlations from lesson 0.7.2. Each element is

$$
\Sigma_{ij} = \sigma_i\,\sigma_j\,\rho_{ij}
$$

and in matrix form, with $D$ the diagonal matrix of volatilities and $C$ the correlation matrix, $\Sigma = D\,C\,D$:

```python
vols = rc.std().to_numpy() * np.sqrt(252)
C = rc.corr().to_numpy()
D = np.diag(vols)
Sigma = D @ C @ D
print(np.allclose(Sigma, rc.cov().to_numpy() * 252))
print(Sigma.round(4))
```
```output
True
[[ 0.0283 -0.0016  0.0031  0.0404]
 [-0.0016  0.0025  0.0014 -0.002 ]
 [ 0.0031  0.0014  0.0228  0.001 ]
 [ 0.0404 -0.002   0.001   0.0799]]
```

The diagonal holds the annual variances ($\sigma_i^2$: 0.0283 for RVAR, a 16.8% volatility squared). Off the diagonal,
the covariances: positive and large between TECH and RVAR, negative between RFIJ and both, almost zero between ORO and
TECH. It is symmetric, because the covariance of A with B is that of B with A.

## The volatility of a portfolio

The portfolio's variance is the **quadratic form** $w^\top \Sigma\, w$: each pair of assets contributes its weight
times its weight times its covariance. With two assets it can be seen in full:

$$
\sigma_p^2 = w_1^2\sigma_1^2 + w_2^2\sigma_2^2 + 2\,w_1 w_2\,\sigma_1\sigma_2\,\rho_{12}
$$

The last term is the one that diversifies: with $\rho_{12} < 1$, the portfolio's variance is smaller than that of the
weighted sum. If all correlations were 1, the portfolio's volatility would be exactly the weighted sum of
volatilities. The cell checks it by replacing $C$ with a matrix of ones and with the identity (zero correlation):

```python
def vol_cartera(w, Sigma):
    return np.sqrt(w @ Sigma @ w)


w_eq = np.full(4, 0.25)
unos = D @ np.ones((4, 4)) @ D
cero = D @ np.eye(4) @ D
print(f"Weighted sum w @ vols:    {w_eq @ vols:.2%}")
print(f"All correlations 1:       {vol_cartera(w_eq, unos):.2%}")
print(f"Actual correlations:      {vol_cartera(w_eq, Sigma):.2%}")
print(f"All correlations 0:       {vol_cartera(w_eq, cero):.2%}")
```
```output
Weighted sum w @ vols:    16.29%
All correlations 1:       16.29%
Actual correlations:      11.68%
All correlations 0:       9.14%
```

The weighted sum (16.29%) matches the correlation-1 case: it is the risk of a portfolio that does not diversify at all.
With the actual correlations the volatility drops to 11.68%, and with zero correlations it would drop to 9.14%. The
difference between the weighted sum and the actual volatility is what diversification contributes, and it depends only
on the correlations.

## Equal weights in capital, not in risk

The **1/N** portfolio puts the same capital in each asset. It is simple and hard to beat, but it has a problem the
total volatility does not show: the volatile assets dominate the risk. Each asset's **risk contribution** is its weight
times its covariance with the portfolio, divided by the portfolio's variance, and the four add up to 1:

$$
\text{RC}_i = \frac{w_i\,(\Sigma w)_i}{w^\top \Sigma\, w}
$$

A simple alternative is **inverse-volatility weights** (1/σ): each asset is weighted in inverse proportion to its
volatility, so the calmest one receives the most capital. The two portfolios, side by side:

```python
def contribuciones(w, Sigma):
    return w * (Sigma @ w) / (w @ Sigma @ w)


w_iv = (1 / vols) / (1 / vols).sum()
tabla = pd.DataFrame({
    "peso_1N": w_eq, "riesgo_1N": contribuciones(w_eq, Sigma),
    "peso_1σ": w_iv, "riesgo_1σ": contribuciones(w_iv, Sigma),
}, index=activos)
print(tabla.round(3))
for nombre, pesos in (("1/N", w_eq), ("1/σ", w_iv)):
    print(f"{nombre:<4} volatility {vol_cartera(pesos, Sigma):.2%}"
          f" · weighted sum {pesos @ vols:.2%}")
```
```output
      peso_1N  riesgo_1N  peso_1σ  riesgo_1σ
RVAR     0.25      0.322    0.164      0.313
RFIJ     0.25      0.001    0.556      0.150
ORO      0.25      0.130    0.182      0.233
TECH     0.25      0.547    0.098      0.303
1/N  volatility 11.68% · weighted sum 16.29%
1/σ  volatility 6.58% · weighted sum 11.03%
```

With equal weights, TECH contributes 54.7% of the risk with 25% of the capital, and RFIJ practically nothing: in risk
terms, the 1/N portfolio is mostly a bet on TECH and RVAR. With 1/σ weights, RFIJ receives more than half the capital
and the contributions are spread much more evenly, between 15% and 31%. They are not equal because 1/σ weights ignore
the correlations: TECH and RVAR, highly correlated, still add more risk than their volatility suggests.

The figure at the top summarizes the two portfolios: each bar is the actual volatility and the light segment, what the
weighted sum of volatilities overstates. The 1/σ portfolio is much less volatile (6.58% versus 11.68%), but that does
not make it better: it also returns less, because fixed income weighs more. Comparing portfolios requires looking at
return and risk together, and that is done in the portfolio construction levels.

<aside class="callout" data-kind="aviso">

Comparing two portfolios by their volatility without equalizing the risk always favors the more conservative one. To
compare them fairly, they are scaled to the same target volatility (*volatility targeting*, link in “Resources”) or
judged by their return per unit of risk.

</aside>

## Why minimum variance is so unstable

Lesson 0.5.1 computed the **minimum-variance** portfolio, with weights proportional to $\Sigma^{-1}\mathbf{1}$, and
left an open question: why are these portfolios so sensitive to the estimate of $\Sigma$? Because they use the
inverse. Inverting a matrix amplifies its errors, and a $\Sigma$ estimated from few data points has many. The test:
estimate $\Sigma$ over consecutive windows of 60 sessions (about three months), compute the minimum-variance and the
1/σ weights in each one, and see how much they move:

```python
mv, iv = [], []
for inicio in range(0, len(R) - 59, 60):
    S = np.cov(R[inicio:inicio + 60], rowvar=False)
    x = np.linalg.solve(S, np.ones(4))
    mv.append(x / x.sum())
    v = np.sqrt(np.diag(S))
    iv.append((1 / v) / (1 / v).sum())
mv, iv = np.array(mv), np.array(iv)
print(f"Windows: {len(mv)}")
for nombre, P in (("min. variance", mv), ("1/σ", iv)):
    rango = P.max(axis=0) - P.min(axis=0)
    rotacion = np.abs(np.diff(P, axis=0)).sum(axis=1).mean()
    print(f"{nombre:<14} weight range {rango.round(2)} · "
          f"mean turnover {rotacion:.0%}")
```
```output
Windows: 21
min. variance  weight range [0.26 0.15 0.19 0.17] · mean turnover 21%
1/σ            weight range [0.06 0.14 0.05 0.03] · mean turnover 8%
```

The data in `cartera_sint.csv` were generated with **constant** correlations and volatilities: all the variation in
the weights is estimation error. Even so, the minimum-variance weights change much more from one window to the next
than the 1/σ ones, flip from long to short in ORO and TECH, and force more than twice as much of the portfolio to be
turned over at each rebalance, with its costs (lesson 0.1.2). The 1/σ weights only use the diagonal of $\Sigma$ (the
volatilities), which is estimated much better than the correlations.

The usual fixes (banning short positions, capping each asset's weight, “shrinking” the covariance matrix toward a
simpler one, using longer windows) are all ways of not fully trusting $\Sigma^{-1}$, and they will be covered with the
portfolio construction techniques. From this lesson it is enough to keep the idea: the more a method relies on the
estimated correlations, the more sensitive it is to their errors.

## Exercises

1. Compute the volatility of a 60/40 portfolio of RVAR and RFIJ with the two-asset formula (written by hand, with
   `vols` and `C`) and check it with `vol_cartera`. How much does the negative correlation contribute compared with a
   zero correlation?

**Solution.**

```python
w1, w2 = 0.6, 0.4
s1, s2, rho = vols[0], vols[1], C[0, 1]
var_formula = (w1**2 * s1**2 + w2**2 * s2**2
               + 2 * w1 * w2 * s1 * s2 * rho)
w_6040 = np.array([w1, w2, 0, 0])
print(f"Formula:           {np.sqrt(var_formula):.3%}")
print(f"w @ Sigma @ w:     {vol_cartera(w_6040, Sigma):.3%}")
sin_corr = np.sqrt(w1**2 * s1**2 + w2**2 * s2**2)
print(f"With correlation 0: {sin_corr:.3%}")
```
```output
Formula:           9.921%
w @ Sigma @ w:     9.921%
With correlation 0: 10.291%
```

The two routes agree. The negative correlation between RFIJ and RVAR (−0.19) takes almost four tenths of a point of
volatility off compared with a zero correlation (9.92% versus 10.29%). Setting the weights of ORO and TECH to zero
reduces the four-asset quadratic form to the two-asset one.

2. Check that the risk contributions of the 1/N portfolio add up to 1 and that `w * (Sigma @ w)` adds up to the
   portfolio's variance. Which asset reduces the portfolio's risk if it is given a little more weight?

**Solution.**

```python
cr = contribuciones(w_eq, Sigma)
print(f"Sum of contributions: {cr.sum():.6f}")
print(np.isclose((w_eq * (Sigma @ w_eq)).sum(),
                 w_eq @ Sigma @ w_eq))
for nombre, c in zip(activos, Sigma @ w_eq):
    print(f"{nombre:<5}{c:8.4f}")
```
```output
Sum of contributions: 1.000000
True
RVAR   0.0176
RFIJ   0.0001
ORO    0.0071
TECH   0.0298
```

The contributions split the total variance with nothing left over and nothing missing. `Sigma @ w` is each asset's
covariance with the portfolio: the one with the lowest is the one that adds the least risk per extra euro. RFIJ has a
practically zero covariance with the 1/N portfolio, so giving it more weight (at the expense of the others) reduces
the volatility: that is what 1/σ weights and minimum variance do, taken to the extreme.

3. Compute the volatility of two 50/50 portfolios: TECH with RVAR, and RVAR with RFIJ. For each one, the weighted sum
   of volatilities and the difference. Which pair diversifies more, and why?

**Solution.**

```python
parejas = {"TECH+RVAR": [0.5, 0, 0, 0.5],
           "RVAR+RFIJ": [0.5, 0.5, 0, 0]}
for nombre, pesos in parejas.items():
    p = np.array(pesos)
    real, suma = vol_cartera(p, Sigma), p @ vols
    print(f"{nombre}: actual {real:.2%} · sum {suma:.2%} · "
          f"difference {suma - real:.2%}")
```
```output
TECH+RVAR: actual 21.74% · sum 22.55% · difference 0.81%
RVAR+RFIJ: actual 8.32% · sum 10.89% · difference 2.58%
```

TECH and RVAR, with a correlation of 0.85, barely diversify: the actual volatility (21.74%) is less than a point below
the weighted sum. RVAR and RFIJ, with a negative correlation, come in 2.58 points below: almost a quarter of their
weighted sum. What diversifies is not the number of assets, but how little their moves resemble each other.

## Self-assessment

1. `R` has shape `(1302, 4)` (days × assets) and `w` shape `(4,)`. What does `R @ w` return?

   - A. A number: the portfolio's total return
   - B. An array of shape `(1302,)`: the portfolio's return each day
   - C. An array of shape `(4,)`: each asset's mean return
   - D. An error, because the shapes do not match

   *Answer B: An array of shape `(1302,)`: the portfolio's return each day.* In a matrix product, the inner dimension (4 assets) disappears and the outer ones remain: 1302 days. Each element is the dot product of the weights with one day's returns.

2. A portfolio holds 50% in each of two assets with 20% volatility each. In which case is its volatility exactly 20%?

   - A. Only if the correlation between the two is 1
   - B. Always: the average of 20% and 20% is 20%
   - C. If the correlation is 0
   - D. Never

   *Answer A: Only if the correlation between the two is 1.* The weighted sum of volatilities equals the portfolio's volatility only with a correlation of 1. With a correlation of 0 it would be 20% / √2 ≈ 14.1%; with −1, zero.

3. In the portfolio with 25% in each asset, TECH contributes around 55% of the risk. Why?

   - A. Because it has a larger weight than the others
   - B. Because its return is the highest
   - C. It is a calculation error: with equal weights, each asset contributes 25%
   - D. Because it is the most volatile and highly correlated with RVAR: its capital weight is the same, but its risk weight is much larger

   *Answer D: Because it is the most volatile and highly correlated with RVAR: its capital weight is the same, but its risk weight is much larger.* Each asset's risk contribution is its weight times its covariance with the portfolio. A volatile asset correlated with the rest weighs much more in risk than in capital.

4. Why do minimum-variance weights estimated over 60-session windows change so much from one window to the next?

   - A. Because the minimum-variance portfolio is ill-defined
   - B. Because prices change
   - C. Because they solve a system with the inverse of a covariance matrix estimated from few data points, and amplify its estimation error
   - D. Because NumPy rounds

   *Answer C: Because they solve a system with the inverse of a covariance matrix estimated from few data points, and amplify its estimation error.* With 60 data points, covariances and correlations carry a lot of error, and solving the system with the inverse amplifies small differences. 1/σ weights only use the volatilities, which are estimated better, and change much less.

## Key points

- A portfolio is a weight vector; its return, the dot product `w @ r` with the period's simple returns.
- In a matrix product, the inner dimension has to match and it disappears: `(1302, 4) @ (4,)` gives `(1302,)`. Checking `shape` prevents silent errors.
- The covariance matrix is Σ = D C D: volatilities on the diagonal of D and correlations in C. The portfolio's variance is w′Σw.
- The weighted sum of volatilities is the portfolio's volatility only if all correlations are 1; the difference is the diversification.
- Equal capital weights are not equal risk weights. 1/σ weights spread risk better and, compared with minimum variance, are much more stable.

## Resources

### Research

- [Volatility Targeting in Python](https://pythonparatrading.com/en/volatility-targeting-2/) · Research-0004 · 7 min

### Documentation

- [NumPy: numpy.matmul (@ operator)](https://numpy.org/doc/stable/reference/generated/numpy.matmul.html) · numpy.org
- [NumPy: numpy.cov](https://numpy.org/doc/stable/reference/generated/numpy.cov.html) · numpy.org

### Data

- [Level 0 data: closes of the fictional portfolio (CSV)](https://pythonparatrading.com/curso/datos/nivel-0/cartera_sint.csv) · Download · cartera_sint.csv

Previous lesson: [0.7.2 · Probability and distributions, correlation versus causation and simple regression](https://pythonparatrading.com/en/python-trading-course/level-0/probability-distributions-and-regression/)
