Analizzare i mercati con le componenti principali
Tecniche statistiche applicate alla finanza
L'analisi delle componenti principali (principal component analysis o PCA) è una tecnica statistica di riduzione della multidimensionalità dei dati ampiamente utilizzata anche nell'analisi dei mercati finanziari. In questo post andremo ad applicarla a un semplice portafoglio di ETF anche in una versione "dinamica", avvalendoci della tecnica delle medie mobili esponenziali.
import numpy as np
import pandas as pd
import altair as alt
import yfinance as yf
def normalize(series):
return series.subtract(series.mean()).div(series.std())
def denormalize(series, mu, sd):
return series.multiply(sd).add(mu)
def format_pc(seq):
return [f'PC{n:02}' for n, _ in enumerate(seq)]
def df_dict(fun, seq):
return {s: fun(s) for n, s in enumerate(seq)}
def df_list(fun, seq):
return [fun(s) for s in seq]
def concat_dict(d):
return pd.concat(d.values(), axis=0, keys=d.keys())
def dot(df1, df2):
return df1.dot(df2.T)
def dot3d(df1, df2):
return df1.multiply(df2).sum(axis=1).unstack()
def cov_matrix(df, norm, ewm, alpha=None):
if ewm:
if norm:
return df.apply(normalize).ewm(alpha=alpha).cov()
return df.ewm(alpha=alpha).cov()
if norm:
return df.apply(normalize).cov()
return df.cov()
def calc_variance(values):
return (values / values.sum()).cumsum()
def calc_pca(df):
values, vectors = np.linalg.eig(df)
return (
pd.Series(calc_variance(values), format_pc(df.index)),
pd.DataFrame(vectors, format_pc(df.index), df.columns)
)
def calc_ewm_pca(df):
ans = df.fillna(0).groupby(level=0).apply(np.linalg.eig)
def vectors(date):
return pd.DataFrame(
np.real(ans.loc[date][1]),
format_pc(df.columns),
df.columns
)
def variance(date):
return calc_variance(np.real(ans.loc[date][0]))
dfs = df_dict(vectors, ans.index)
return (
pd.DataFrame(
df_list(variance, ans.index),
ans.index,
format_pc(df.columns)
),
concat_dict(dfs)
)
def calc_pc_returns(vectors, returns, norm):
if not norm:
return dot(returns, vectors)
mu = dot(returns.mean(), vectors)
sd = dot(returns.std(), vectors)
return denormalize(dot(returns.apply(normalize), vectors), mu, sd)
def calc_ewm_pc_returns(vectors, returns, norm):
if not norm:
return returns.T.apply(lambda c: vectors.xs(c.name).dot(c)).T
mu = dot3d(vectors, returns.mean())
sd = dot3d(vectors, returns.std())
return denormalize(returns.apply(normalize).T.apply(lambda c: vectors.xs\
(c.name).dot(c)).T, mu, sd)
Scarichiamo le serie storiche di alcuni ETF di Vanguard, tra cui i quattro che suggerisce come "mattoncini di base" nel portfolio builder dedicato agli investitori americani. Aggiungiamo due ETF dedicati al real estate e alle commodities e scarichiamo anche la serie dei prezzi dell'indice VIX, la volatilità del listino statunitense.
tickers = [
'VTI', # Vanguard Total Stock Market
'VXUS', # Vanguard Total International Stock
'BND', # Vanguard Total Bond Market
'BNDX', # Vanguard Total International Bond
'VNQ', # Vanguard Real Estate Index Fund
'GSG', # iShares S&P GSCI Commodity-Indexed Trust
'^VIX' # Indice VIX
]
data = yf.download(tickers=tickers, period='2y')
prices = data.xs('Adj Close', axis=1, level=0)
Ribasiamo i prezzi dei nostri ETF a 100 e disegniamo un primo grafico per vederne l'andamento storico.
rebased_prices = prices.iloc[:, :6].div(prices.iloc[0, :6]).mul(100)
alt.Chart(
rebased_prices.reset_index().melt(
'Date',
var_name='ETF',
value_name='Price'
)
).mark_line().encode(
x='Date:T',
y='Price:Q',
color='ETF:N',
row='ETF:N',
tooltip=['ETF', 'Date', 'Price']
).properties(
title='Andamento prezzi ETF ribasati negli ultimi due anni'
).interactive()
Calcoliamo i rendimenti giornalieri delle serie storiche e la matrice di correlazione tra gli ETF.
returns = rebased_prices.pct_change()
returns.corr()
Per calcolare le componenti principali, è preferibile utilizzare una matrice di covarianza, a partire dai rendimenti standardizzati), ossia da serie con media nulla e varianza pari a 1.
covmat = cov_matrix(returns, True, False)
Il calcolo matriciale delle componenti principali prevede di trovare autovalori e autovettori della matrice di covarianza, qui calcoliamo già un vettore di autovalori cumulati, che andiamo poi a disegnare come grafico a barre.
variance, vectors = calc_pca(covmat)
Dal grafico possiamo vedere che la prima componente principale (PC00), spiega poco meno del 60% della varianza complessiva dei rendimenti degli indici, mentre la somma delle prime due componenti portano la varianza spiegata quasi all'80%. Chiaramente, in questo portafoglio di 6 strumenti, la necessità di ridurre la dimensionalità è modesta, ma la tecnica è utile laddove ci siano decine o centinaia di indici o titoli per identificare le componenti principali da utilizzare poi per ulteriori analisi.
alt.Chart(
variance.rename('Percentuale').to_frame().reset_index()
).mark_bar().encode(
y='index:N',
x='Percentuale:Q',
tooltip=['index', 'Percentuale']
).properties(
title='Varianza cumulata spiegata dalle componenti principali'
).interactive()
A partire dalla matrice degli autovettori possiamo inoltre ricostruire i "rendimenti" delle componenti principali e i valori delle serie, come se fossero prezzi di serie finanziarie. Lo facciamo nel grafico successivo, ricordando che le prime due componenti principali sono contrassegnate dalla dicitura PC00 e PC01.
pc_returns = calc_pc_returns(vectors, returns, True)
pc_prices = pc_returns.add(1).cumprod().multiply(100)
alt.Chart(
pc_prices.reset_index().melt(
'Date',
var_name='PC',
value_name='Price'
)
).mark_line().encode(
x='Date:T',
y='Price:Q',
color='PC:N',
row='PC:N',
tooltip=['PC', 'Date', 'Price']
).properties(
title='Andamento valori componenti principali negli ultimi due anni'
).interactive()
Le componenti principali sono calcolate sul periodo di analisi e dipendono pertanto dalla finestra temporale scelta. Ci si potrebbe domandare: è possibile averne una versione dinamica, rolling che non dipenda da una finestra temporale fissa? Proviamo ad applicare la tecnica calcolando delle matrici di covarianza rolling con la metodologia delle medie mobili esponenziali, applicando un fattore di decadimento (alpha), pari a 0.06 - per ogni osservazione, il 94% deriva dal dato precedente e il 6% è introdotto sulla base del nuovo dato. Disegniamo poi il grafico della varianza spiegata dalla prima componente principale, mettendolo a confronto con l'andamento dell'indice VIX.
ewm_covmat = cov_matrix(returns, True, True, 0.06)
Interessante: la varianza spiegata dalla prima componente principale arriva fino a più dell'80% (mentre l'ultimo dato è al 40%) esattamente in coincidenza con il picco del VIX, ossia della correzione dei mercati di marzo 2020. Il che è in totale accordo con l'ipotesi che la correlazione dei mercati e delle classi di attivi cresca nei momenti di maggiore volatilità e di correzione.
import warnings
warnings.filterwarnings('ignore')
ewm_variance, ewm_vectors = calc_ewm_pca(ewm_covmat)
alt.Chart(
pd.concat([100 * ewm_variance.iloc[20:, 0], prices.iloc[:, 6]], axis=1).reset_index().melt(
'Date',
var_name='Index',
value_name='Value'
)
).mark_line().encode(
x='Date:T',
y='Value:Q',
color='Index:N',
row='Index:N',
tooltip=['Index', 'Date', 'Value']
).properties(
title='Confronto andamento prima PC e indice VIX'
).interactive()
Possiamo infine calcolare gli andamenti storici delle componenti principali, che saranno certamente differenti da quelle "statiche"; in particolare, appaiono più volatili in coincidenza con il periodo di crisi di febbraio - aprile 2020.
ewm_pc_returns = calc_ewm_pc_returns(ewm_vectors, returns, False)
ewm_pc_prices = ewm_pc_returns.add(1).cumprod().multiply(100)
alt.Chart(
ewm_pc_prices.reset_index().melt(
'Date',
var_name='PC',
value_name='Price'
)
).mark_line().encode(
x='Date:T',
y='Price:Q',
color='PC:N',
row='PC:N',
tooltip=['PC', 'Date', 'Price']
).properties(
title='Andamento valori componenti principali EW negli ultimi due anni'
).interactive()