Portafoglio di Markowitz
Classe: QP convesso · Script: python/lab06_markowitz.py
Quanto investire in ciascun titolo per bilanciare rendimento atteso e rischio? Il risultato non è un numero ma una frontiera efficiente: il menu completo dei compromessi tra cui il decisore sceglie.
Il problema a parole. Decidiamo le quote di capitale \(x_i\). L'obiettivo: minima varianza del portafoglio. I vincoli: quote a somma 1, rendimento atteso minimo \(\bar r\), limiti \(\ell_i \le x_i \le u_i\).
Modello
Dati (input del modello).
| Simbolo | Tipo | Significato |
|---|---|---|
| \(n\) | \(\in \mathbb{Z}_{\ge 1}\) | numero di titoli; i titoli sono indicizzati da \(i \in \{1, 2, \dots, n\}\) |
| \(\mu_i\) | \(\in \mathbb{Q}\) | rendimento atteso del titolo \(i\) (per anno) |
| \(q_{ij}\) | \(\in \mathbb{Q}\) | covarianza tra i rendimenti dei titoli \(i\) e \(j\); la matrice \(\boldsymbol Q = (q_{ij})\) è semidefinita positiva (\(\boldsymbol Q \succeq 0\)) |
| \(\bar r\) | \(\in \mathbb{Q}\) | rendimento atteso minimo richiesto |
| \(\ell_i,\, u_i\) | \(\in \mathbb{Q},\ 0 \le \ell_i \le u_i \le 1\) | quota minima e massima investibile nel titolo \(i\) |
Variabili decisionali. Introduciamo le seguenti \(n\) variabili non negative:
Usando queste variabili, un modello QP per il problema è il seguente:
Descrizione della funzione obiettivo e dei vincoli:
- la funzione obiettivo quadratica minimizza la varianza del rendimento del portafoglio; contiene le covarianze \(q_{ij}\), ed è qui che nasce la diversificazione: combinare titoli poco correlati abbassa il rischio complessivo sotto quello dei singoli componenti;
- il vincolo lineare di rendimento impone che il rendimento atteso del portafoglio raggiunga la soglia \(\bar r\): è la "manopola" con cui si percorre la frontiera efficiente, e il suo moltiplicatore dice quanta varianza costa ogni punto di rendimento in più (un vincolo lineare);
- il vincolo lineare di budget impone di investire tutto il capitale (un vincolo lineare);
- i vincoli lineari di tetto \(x_i \le u_i\) sono i limiti per titolo, tipici vincoli regolamentari o di mandato (\(n\) vincoli lineari);
- i vincoli \(x_i \ge \ell_i\) definiscono le variabili del modello (con \(\ell_i = 0\): divieto di vendita allo scoperto).
Formulazioni equivalenti: \(\max\, \sum_{i=1}^{n} \mu_i x_i - \lambda \sum_{i=1}^{n}\sum_{j=1}^{n} q_{ij} x_i x_j\) (media-varianza) e \(\max\, \sum_{i=1}^{n} \mu_i x_i\) con varianza \(\le \bar\sigma^2\) (massimo rendimento a rischio limitato). Variando \(\bar r\) (o \(\lambda\), o \(\bar\sigma\)) si percorre la stessa frontiera.
Ogni matrice di covarianza è semidefinita positiva, quindi il QP è convesso e l'ottimo trovato è globale certificato.
Esempio a mano (2 titoli non correlati)
\(\sigma_1 = 20\%\), \(\sigma_2 = 30\%\): minimizzando \(0{,}04 x_1^2 + 0{,}09 (1 - x_1)^2\) si ottiene \(x_1 = 9/13 = 69{,}2\%\) e volatilità di portafoglio \(16{,}6\%\) — meno di entrambi i titoli.
Caso di studio
Otto ETF settoriali, \(\boldsymbol\mu\) e \(\boldsymbol Q\) stimati da 60 rendimenti
mensili simulati (dati/markowitz_rendimenti.csv). Statistiche annualizzate:
ENE: mu = 19,12% vol = 18,65% SAN: mu = 2,80% vol = 11,93%
FIN: mu = 8,27% vol = 19,59% CON: mu = 11,25% vol = 11,67%
TEC: mu = 1,58% vol = 29,27% UTL: mu = 11,99% vol = 7,53%
IND: mu = 9,97% vol = 16,60% MAT: mu = 20,93% vol = 23,93%
Minima varianza globale : rendimento 11,06%, volatilita' 6,03%
Equipesato (1/n) : rendimento 10,74%, volatilita' 10,13%
Composizione min varianza: ENE 5,2% IND 1,9% SAN 11,5% CON 22,3% UTL 58,3%


Tre messaggi: (1) il portafoglio di minima varianza (volatilità 6%) è molto meno rischioso del miglior titolo singolo (UTL, 7,5%) pur rendendo l'11%; (2) l'equipesato \(1/n\) — la strategia "non so nulla" — è nettamente dominato: stessa area di rendimento ma quasi il doppio della volatilità; (3) il tetto \(u_i = 30\%\) taglia la parte alta della frontiera — il costo dei vincoli di mandato si vede come distanza tra le due curve.
Sensitività
r_min = 6%: vol 6,03% (vincolo NON attivo: coincide col min varianza)
r_min = 8%: vol 6,03% (idem)
r_min = 10%: vol 6,03% (idem)
r_min = 12%: vol 6,10% d(varianza)/d(r_min) ~ 0,0221
Fino a \(\bar r = 11\%\) il vincolo di rendimento è inattivo: il portafoglio di minima varianza rende già l'11,06%, quindi chiedere "almeno l'8%" non costa nulla e il moltiplicatore è zero. Solo oltre l'11,06% il vincolo morde e ogni punto di rendimento in più si paga in varianza (moltiplicatore \(\approx 0{,}022\) a \(\bar r = 12\%\)).
La fragilità delle stime
Nei dati simulati il titolo TEC ha \(\alpha\) vero dell'11% annuo, ma su 60 mesi il rendimento stimato è 1,6%: il rumore domina. Le stime dei rendimenti attesi sono molto più instabili di quelle delle covarianze, e i portafogli ottimizzati inseguono gli errori di stima. Rimedi: vincoli \(u_i\), shrinkage delle stime, oppure ottimizzare solo il rischio (minima varianza).
Codice
Lo script completo del capitolo — dati, modello, soluzione, sensitività e figure —
è python/lab06_markowitz.py
(riproducibile con python3 python/lab06_markowitz.py dalla cartella python/).
Lo stesso codice è disponibile come notebook — notebooks/lab06_markowitz.ipynb — che si apre in Colab dal badge in cima alla pagina e gira nel browser, senza installare niente.
Mostra lo script completo — lab06_markowitz.py
"""Capitolo 6 — Portafoglio di Markowitz (QP convesso).
Caso di studio: 8 titoli (ETF settoriali), 60 rendimenti mensili simulati con
un modello a un fattore di mercato + rumore idiosincratico.
Contenuto:
1. Stima di mu e Q dai dati storici
2. Portafoglio a minima varianza globale e con rendimento minimo
3. Frontiera efficiente e composizione lungo la frontiera
4. Effetto dei limiti massimi per titolo (u_i)
"""
import gurobipy as gp
import numpy as np
import pandas as pd
from gurobipy import GRB
from stile import (ARANCIO, CICLO, GRIGIO, ROSSO, TEAL, intestazione, plt, salva_dat,
salva_dati, salva_figura)
rng = np.random.default_rng(42)
# ----------------------------------------------------------------------
# 1. DATI: 60 mesi di rendimenti simulati (modello a un fattore)
# ----------------------------------------------------------------------
titoli = ["ENE", "FIN", "TEC", "IND", "SAN", "CON", "UTL", "MAT"]
n, T = len(titoli), 60
beta = np.array([1.1, 1.3, 1.5, 1.0, 0.6, 0.8, 0.4, 1.2]) # esposizione al mercato
alfa_ann = np.array([0.05, 0.06, 0.11, 0.05, 0.045, 0.05, 0.035, 0.06]) # extra-rendimento
sigma_idio = np.array([0.05, 0.055, 0.07, 0.04, 0.03, 0.035, 0.02, 0.06]) # vol. mensile idio
mercato = rng.normal(0.004, 0.035, T) # fattore di mercato mensile
R = (alfa_ann[None, :] / 12 + np.outer(mercato, beta)
+ rng.normal(0, sigma_idio, (T, n))) # matrice T x n dei rendimenti
rend = pd.DataFrame(R, columns=titoli)
rend.insert(0, "mese", range(1, T + 1))
salva_dati(rend, "markowitz_rendimenti")
mu = R.mean(axis=0) * 12 # rendimento atteso annualizzato
Q = np.cov(R.T) * 12 # covarianza annualizzata
vol = np.sqrt(np.diag(Q))
intestazione("Statistiche dei titoli (annualizzate)")
for i, tt in enumerate(titoli):
print(f" {tt}: mu = {mu[i]:6.2%} vol = {vol[i]:6.2%}")
def portafoglio(r_min=None, u=1.0):
"""QP: minima varianza con eventuale rendimento minimo e limite per titolo."""
m = gp.Model("markowitz")
m.Params.OutputFlag = 0
x = m.addVars(n, ub=u, name="x")
m.addConstr(x.sum() == 1, name="budget")
if r_min is not None:
m.addConstr(gp.quicksum(mu[i] * x[i] for i in range(n)) >= r_min, name="rendimento")
m.setObjective(gp.quicksum(Q[i, j] * x[i] * x[j]
for i in range(n) for j in range(n)), GRB.MINIMIZE)
m.optimize()
if m.Status != GRB.OPTIMAL:
return None, None, None
w = np.array([x[i].X for i in range(n)])
return w, float(mu @ w), float(np.sqrt(w @ Q @ w))
# ----------------------------------------------------------------------
# 2. PORTAFOGLI NOTEVOLI
# ----------------------------------------------------------------------
intestazione("Portafogli notevoli")
w_mv, r_mv, v_mv = portafoglio()
print(f"Minima varianza globale : rendimento {r_mv:6.2%}, volatilità {v_mv:6.2%}")
w_eq = np.ones(n) / n
print(f"Equipesato (1/n) : rendimento {mu @ w_eq:6.2%}, "
f"volatilità {np.sqrt(w_eq @ Q @ w_eq):6.2%}")
r_obb = 0.08
w_8, r_8, v_8 = portafoglio(r_min=r_obb)
print(f"Rendimento minimo 8% : rendimento {r_8:6.2%}, volatilità {v_8:6.2%}")
print("\nComposizione (quote > 1%):")
for nome, w in [("min varianza", w_mv), ("rend. min 8%", w_8)]:
quote = ", ".join(f"{titoli[i]} {w[i]:.1%}" for i in range(n) if w[i] > 0.01)
print(f" {nome:>14}: {quote}")
# ----------------------------------------------------------------------
# 3. FRONTIERA EFFICIENTE (con e senza limite u_i = 30%)
# ----------------------------------------------------------------------
intestazione("Frontiera efficiente")
r_grid = np.linspace(r_mv, mu.max() * 0.999, 30)
frontiere = {}
for u, etich in [(1.0, "senza limiti"), (0.30, "u_i = 30%")]:
punti, composizioni = [], []
for r in r_grid:
w, rr, vv = portafoglio(r_min=r, u=u)
if w is not None:
punti.append((vv, rr))
composizioni.append(w)
frontiere[etich] = (np.array(punti), np.array(composizioni))
print(f" frontiera '{etich}': {len(punti)} punti calcolati")
pf = frontiere["senza limiti"][0]
salva_dati(pd.DataFrame({"volatilita": pf[:, 0], "rendimento": pf[:, 1]}),
"markowitz_frontiera")
# ----------------------------------------------------------------------
# 4. FIGURE (dati pgfplots + anteprima matplotlib)
# ----------------------------------------------------------------------
pf_lim = frontiere["u_i = 30%"][0]
salva_dat(pd.DataFrame({"vol": pf[:, 0] * 100, "rend": pf[:, 1] * 100}), "cap06_front_libera")
salva_dat(pd.DataFrame({"vol": pf_lim[:, 0] * 100, "rend": pf_lim[:, 1] * 100}),
"cap06_front_limiti")
salva_dat(pd.DataFrame({"titolo": titoli, "vol": vol * 100, "mu": mu * 100}), "cap06_titoli")
salva_dat(pd.DataFrame({
"nome": ["minvar", "equipesato"],
"vol": [v_mv * 100, float(np.sqrt(w_eq @ Q @ w_eq)) * 100],
"rend": [r_mv * 100, float(mu @ w_eq) * 100],
}), "cap06_speciali")
punti_sl, comp_sl = frontiere["senza limiti"]
salva_dat(pd.DataFrame({"rend": punti_sl[:, 1] * 100,
**{titoli[i]: comp_sl[:, i] * 100 for i in range(n)}}),
"cap06_composizione")
fig, ax = plt.subplots()
for (etich, (punti, _)), colore in zip(frontiere.items(), [TEAL, ARANCIO]):
ax.plot(punti[:, 0] * 100, punti[:, 1] * 100, "-", color=colore, lw=2, label=etich)
ax.scatter(vol * 100, mu * 100, color=GRIGIO, s=28, zorder=3, label="titoli singoli")
for i, tt in enumerate(titoli):
ax.annotate(" " + tt, (vol[i] * 100, mu[i] * 100), fontsize=8, color=GRIGIO)
ax.scatter([v_mv * 100], [r_mv * 100], marker="*", s=200, color=ROSSO, zorder=4,
label="minima varianza")
ax.scatter([np.sqrt(w_eq @ Q @ w_eq) * 100], [mu @ w_eq * 100], marker="D", s=60,
color="#8E44AD", zorder=4, label="equipesato 1/n")
ax.set_xlabel("volatilità annua (%)")
ax.set_ylabel("rendimento atteso annuo (%)")
ax.set_title("Frontiera efficiente: la diversificazione domina i titoli singoli")
ax.legend(fontsize=8)
salva_figura(fig, "cap06_frontiera")
punti, comp = frontiere["senza limiti"]
fig, ax = plt.subplots()
ax.stackplot(punti[:, 1] * 100, (comp.T * 100), labels=titoli, colors=CICLO, alpha=0.9)
ax.set_xlabel("rendimento richiesto $\\bar r$ (%)")
ax.set_ylabel("composizione del portafoglio (%)")
ax.set_title("Composizione ottima lungo la frontiera")
ax.legend(fontsize=7, ncol=4, loc="lower left")
ax.set_ylim(0, 100)
salva_figura(fig, "cap06_composizione")
# sensitività: prezzo del vincolo di rendimento (moltiplicatore ~ pendenza frontiera)
intestazione("Sensitività: costo (in varianza) del rendimento richiesto")
for r in [0.06, 0.08, 0.10, 0.12]:
w, rr, vv = portafoglio(r_min=r)
if w is None:
print(f" r_min = {r:.0%}: inammissibile (oltre il rendimento massimo)")
continue
eps = 0.002
w2, _, vv2 = portafoglio(r_min=r + eps)
pend = (vv2**2 - vv**2) / eps if w2 is not None else float("nan")
print(f" r_min = {r:5.1%}: vol {vv:6.2%} | d(varianza)/d(r_min) ≈ {pend:7.4f}")
print("\nFatto: capitolo 6.")
Esercizi
- Due titoli con \(\rho = 0{,}5\): la diversificazione conviene ancora? (\(x_1 = 6/7\), vol 19,6%)
- Frontiere con \(u = 1\), \(0{,}3\), \(0{,}2\): rendimento massimo 20,9% / 16,7% / 14,7%.
- Rieseguire con seed diverso: quanto cambiano le composizioni?
- Tracking error minimo con rendimento ≥ 12% (TE = 1,1% annuo).
- Costi di transazione quadratici dal portafoglio equipesato.