Lesson 0.7.3 · 50 min
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.
- Module
- 0.7 · Minimum mathematics and statistics
- Duration
- 50 min
- Prerequisites
- 0.7.2 · Probability and distributions, correlation versus causation and simple regression, 0.5.1 · NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- Author
- Jesús Cuesta
- Updated
Level 0 lessons
0.1 · Welcome and expectations
0.2 · Modern working environment
- 0.2.1 Installing Python with uv: versions and support cycle
- 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
- 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Basic terminal, paths, environment variables and .env files
- 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
- 0.2.6 Introduction to AI coding assistants: prompts, review and tests
0.3 · Language fundamentals
- 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
- 0.3.2 Strings, f-strings and formatting numbers and dates
- 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
- 0.3.4 Control flow: if, elif, else, for, while and match-case
- 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
- 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
0.4 · Intermediate Python
- 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
- 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
- 0.4.3 Iterators, generators, decorators and context managers
- 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
- 0.4.5 Professional logging and testing with pytest
- 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
0.5 · Numerical computing and data
- 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
- 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
- 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
0.6 · Visualisation
0.7 · Minimum mathematics and statistics
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:
The dot product 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:
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%}")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 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 source
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 , the
number of columns of has to equal the number of rows of , and that dimension disappears from the result.
times gives :
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%}")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:
w @ RValueError: 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 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 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
and in matrix form, with the diagonal matrix of volatilities and the correlation matrix, :
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))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 (: 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 : each pair of assets contributes its weight times its weight times its covariance. With two assets it can be seen in full:
The last term is the one that diversifies: with , 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 with a matrix of ones and with the identity (zero correlation):
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%}")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:
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:
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%}") 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.
Why minimum variance is so unstable
Lesson 0.5.1 computed the minimum-variance portfolio, with weights proportional to , and left an open question: why are these portfolios so sensitive to the estimate of ? Because they use the inverse. Inverting a matrix amplifies its errors, and a estimated from few data points has many. The test: estimate 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:
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%}")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 (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 , 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
- Compute the volatility of a 60/40 portfolio of RVAR and RFIJ with the two-asset formula (written by hand, with
volsandC) and check it withvol_cartera. How much does the negative correlation contribute compared with a zero correlation?
Solution
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%}")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.
- 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
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}")Sum of contributions: 1.000000
True
RVAR 0.0176
RFIJ 0.0001
ORO 0.0071
TECH 0.0298The 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.
- 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
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%}")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.
Each answer is folded under its question
Self-assessment
Rhas shape(1302, 4)(days × assets) andwshape(4,). What doesR @ wreturn?- AA number: the portfolio's total return
- BAn array of shape
(1302,): the portfolio's return each day - CAn array of shape
(4,): each asset's mean return - DAn error, because the shapes do not match
Show answer
Answer B. An array of shape
(1302,): the portfolio's return each dayIn 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.
A portfolio holds 50% in each of two assets with 20% volatility each. In which case is its volatility exactly 20%?
- AOnly if the correlation between the two is 1
- BAlways: the average of 20% and 20% is 20%
- CIf the correlation is 0
- DNever
Show answer
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.
In the portfolio with 25% in each asset, TECH contributes around 55% of the risk. Why?
- ABecause it has a larger weight than the others
- BBecause its return is the highest
- CIt is a calculation error: with equal weights, each asset contributes 25%
- DBecause it is the most volatile and highly correlated with RVAR: its capital weight is the same, but its risk weight is much larger
Show answer
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.
Why do minimum-variance weights estimated over 60-session windows change so much from one window to the next?
- ABecause the minimum-variance portfolio is ill-defined
- BBecause prices change
- CBecause they solve a system with the inverse of a covariance matrix estimated from few data points, and amplify its estimation error
- DBecause NumPy rounds
Show answer
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 @ rwith the period's simple returns. - In a matrix product, the inner dimension has to match and it disappears:
(1302, 4) @ (4,)gives(1302,). Checkingshapeprevents 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.