Workshop 1 - Hedge Funds — Soluciones detalladas de los Challenges

M6 – Hedge Funds | Documento de apoyo (respuestas en español)

Author

Alberto Dorantes, Ph.D.

Published

September 4, 2026

Abstract
Este documento contiene la solución completa y comentada de los 7 Challenges del Workshop 1. Cada respuesta incluye: (1) el razonamiento conceptual explicado en español y con el mínimo de jerga, (2) el código de Python documentado línea por línea, y (3) la interpretación del output, que es la parte que realmente se evalúa. El documento es autocontenido: se puede correr de principio a fin sin haber ejecutado antes el Workshop.

1 Cómo usar este documento

Este es el solucionario del Workshop 1. Está pensado para que lo leas después de haber intentado los Challenges por tu cuenta, no antes: el valor de un ejercicio de este tipo está en equivocarte primero.

Tres advertencias importantes antes de empezar:

ImportantLos números que verás no serán idénticos a los míos

Este documento descarga los datos en vivo de Yahoo Finance y de la biblioteca de Kenneth French (igual que el Workshop, con USE_LOCAL_DATA = False). Eso significa que si tú lo corres otro día, con un mes más de datos, los números cambiarán un poco. Eso es normal y es parte de la lección: en finanzas, los estimadores se mueven cuando se mueve la muestra.

Lo que no debe cambiar es la interpretación: el signo de un coeficiente, el orden de magnitud, y la conclusión económica. Cuando leas mi interpretación, fíjate en la lógica, no en el cuarto decimal.

TipLa interpretación vale más que el código

En el Workshop está escrito con todas sus letras: “Code without interpretation gets no credit”. Un chunk de código que corre y arroja un número no es una respuesta. La respuesta es la frase que explica qué significa ese número y por qué le debería importar a un inversionista. En cada Challenge vas a ver la sección “Interpretación”: ése es el nivel de detalle que se espera de ti.

NoteEstructura de cada solución

Cada inciso sigue el mismo patrón:

  1. La idea — qué te están preguntando en realidad, en una o dos frases.
  2. El código — comentado en español, línea por línea.
  3. Interpretación — la lectura del output.

2 Setup: librerías, datos y objetos base

Esta sección replica el setup del Workshop para que este documento corra solo. Si ya tienes tu notebook con el Workshop corrido, puedes saltarte esta sección.

2.1 Librerías

import numpy as np                       # álgebra matricial y operaciones vectorizadas
import pandas as pd                      # manejo de tablas (DataFrames) y series de tiempo
import matplotlib.pyplot as plt          # gráficas
import seaborn as sb                     # gráficas estadísticas (heatmaps, regplot)
import statsmodels.api as sm             # modelos estadísticos
import statsmodels.formula.api as smf    # regresiones con "fórmulas" tipo y ~ x
from scipy import optimize, stats        # optimización (SLSQP, brentq) y distribuciones

# Formato de impresión: 4 decimales y separador de miles. Sólo es cosmético,
# pero hace que las tablas se puedan leer sin esfuerzo.
pd.set_option("display.float_format", lambda x: f"{x:,.4f}")
np.set_printoptions(suppress=True, precision=4)

2.2 Descarga de los datos

USE_LOCAL_DATA = False    # False = descarga en vivo (Yahoo Finance + Kenneth French)

TICKERS = ["AAPL", "GLD", "HYG", "JPM", "SPY", "TSLA", "WMT", "XOM"]
START, END = "2014-12-01", "2026-08-01"

if USE_LOCAL_DATA:
    prices = pd.read_csv("hf_prices_monthly.csv", index_col="Date", parse_dates=True)
    ff = pd.read_csv("hf_ff5_rf_monthly.csv", index_col="Date", parse_dates=True)
else:
    import yfinance as yf
    import pandas_datareader.data as web
    # Precios AJUSTADOS mensuales: "ajustado" = ya incluye dividendos y splits,
    # así que el rendimiento que calculemos será un RENDIMIENTO TOTAL.
    prices = yf.download(tickers=" ".join(TICKERS), start=START, end=END,
                         interval="1mo", auto_adjust=True)["Close"]
    prices.index = prices.index + pd.offsets.MonthEnd(0)   # fecha = último día del mes

    # Factores Fama-French (vienen en %, por eso dividimos entre 100)
    ff = web.DataReader("F-F_Research_Data_5_Factors_2x3", "famafrench",
                        start=START, end=END)[0] / 100
    ff.columns = ["Mkt_RF", "SMB", "HML", "RMW", "CMA", "RF"]
    ff.index = ff.index.to_timestamp("M")

[                       0%                       ]
[************          25%                       ]  2 of 8 completed
[******************    38%                       ]  3 of 8 completed
[**********************50%                       ]  4 of 8 completed
[**********************62%*****                  ]  5 of 8 completed
[**********************75%***********            ]  6 of 8 completed
[**********************88%*****************      ]  7 of 8 completed
[*********************100%***********************]  8 of 8 completed
print("Instrumentos (ojo: yfinance los ordena alfabéticamente):", list(prices.columns))
Instrumentos (ojo: yfinance los ordena alfabéticamente): ['AAPL', 'GLD', 'HYG', 'JPM', 'SPY', 'TSLA', 'WMT', 'XOM']
print("Periodo de precios:", prices.index.min().date(), "a", prices.index.max().date())
Periodo de precios: 2014-12-31 a 2026-07-31

2.3 Rendimientos y objetos que usaremos en todos los Challenges

# --- Los dos tipos de rendimiento -------------------------------------------
# r  = rendimiento continuamente compuesto (cc, o logarítmico): SUMA en el tiempo
# Rs = rendimiento simple (aritmético): SUMA entre activos (por eso sirve para carteras)
r  = np.log(prices).diff().dropna()
Rs = prices.pct_change().dropna()

# Alineamos ambos con el archivo de factores para trabajar EXACTAMENTE los mismos meses.
# Si no alineas, statsmodels puede correr una regresión con meses desfasados y
# el resultado se ve "razonable" pero está mal. Es el error silencioso más común.
r,  ff = r.align(ff,  join="inner", axis=0)
Rs, _  = Rs.align(ff, join="inner", axis=0)

print("Meses de datos:", len(r), "| de", r.index.min().date(), "a", r.index.max().date())
Meses de datos: 138 | de 2015-01-31 a 2026-06-30
# --- Momentos de cada activo -------------------------------------------------
mean_m = r.mean()                      # media mensual del rendimiento cc
ER_a   = np.exp(12 * mean_m) - 1       # E[R] ANUAL simple (media geométrica anualizada)
vol_a  = r.std() * np.sqrt(12)         # volatilidad anual (regla de la raíz del tiempo)

# --- Tasa libre de riesgo y rendimientos premio ------------------------------
rf_m     = np.log(1 + ff["RF"])        # tasa libre de riesgo mensual, en cc
mkt_prem = ff["Mkt_RF"]                # premio de mercado (factor de Fama-French)

# Rendimiento premio (excess return) de cada instrumento = r_i - r_f
premR = r.sub(rf_m, axis=0).rename(columns=lambda c: c + "_prem")

pd.DataFrame({"E[R] anual": ER_a, "SD anual": vol_a}).sort_values("E[R] anual",
                                                                  ascending=False)
        E[R] anual  SD anual
Ticker                      
TSLA        0.3376    0.5613
AAPL        0.2398    0.2670
JPM         0.1860    0.2384
WMT         0.1482    0.1903
SPY         0.1375    0.1504
GLD         0.1077    0.1543
XOM         0.0792    0.2657
HYG         0.0439    0.0729
# --- Tabla CAPM de los 8 instrumentos (la usaremos en los Challenges 4 y 5) ---
def capm_table(prem_col, mkt=mkt_prem, prem=premR):
    """Corre la regresión CAPM de un instrumento y devuelve sus estadísticos clave."""
    d = pd.DataFrame({"y": prem[prem_col].values, "x": mkt.values}, index=mkt.index)
    m = smf.ols("y ~ x", data=d).fit()
    ci = m.conf_int()
    return pd.Series({
        "alpha (monthly)":  m.params.iloc[0],
        "alpha annualized": m.params.iloc[0]*12,
        "alpha p-value":    m.pvalues.iloc[0],
        "beta":             m.params.iloc[1],
        "beta SE":          m.bse.iloc[1],
        "beta t-stat":      m.tvalues.iloc[1],
        "beta CI low":      ci.iloc[1, 0],
        "beta CI high":     ci.iloc[1, 1],
        "R-squared":        m.rsquared,
        "resid SD (monthly)": np.sqrt(m.mse_resid)})

capm_all = pd.DataFrame({c.replace("_prem", ""): capm_table(c) for c in premR.columns}).T
betas = capm_all["beta"]
capm_all.sort_values("beta").round(4)
      alpha (monthly)  alpha annualized  ...  R-squared  resid SD (monthly)
GLD            0.0064            0.0768  ...     0.0024              0.0444
HYG           -0.0018           -0.0218  ...     0.6379              0.0127
WMT            0.0055            0.0664  ...     0.1256              0.0513
XOM           -0.0030           -0.0359  ...     0.2006              0.0689
SPY           -0.0006           -0.0068  ...     0.9880              0.0048
JPM            0.0014            0.0172  ...     0.5236              0.0476
AAPL           0.0050            0.0602  ...     0.4241              0.0588
TSLA           0.0052            0.0629  ...     0.2283              0.1430

[8 rows x 10 columns]
# --- Universo de 6 activos riesgosos, carteras óptimas ------------------------
ASSETS = ["AAPL", "JPM", "WMT", "XOM", "TSLA", "GLD"]

mu_a  = np.exp(12 * r[ASSETS].mean()).values - 1    # vector de E[R] anuales
COV_A = (12 * r[ASSETS].cov()).values               # matriz de var-cov ANUAL
RF    = 0.035                                       # supuesto de tasa libre de riesgo anual

ones  = np.ones(len(ASSETS))
Sinv  = np.linalg.inv(COV_A)

# Cartera de mínima varianza global (GMV): no usa los rendimientos esperados
w_gmv = Sinv @ ones / (ones @ Sinv @ ones)

# Cartera de tangencia (máximo Sharpe), solución cerrada, permite ventas en corto
z     = Sinv @ (mu_a - RF*ones)
w_tan = z / (ones @ z)

def port_perf(w, mu=mu_a, S=COV_A, rf=RF):
    """Devuelve (rendimiento esperado, volatilidad, Sharpe) de una cartera."""
    ER = w @ mu
    s  = np.sqrt(w @ S @ w)
    return ER, s, (ER - rf)/s

# Cartera de tangencia con restricción de NO ventas en corto (long-only)
def neg_sharpe(w):
    ER, s, _ = port_perf(w)
    return -(ER - RF)/s

opt = optimize.minimize(neg_sharpe, np.repeat(1/len(ASSETS), len(ASSETS)),
                        method="SLSQP", bounds=[(0, 1)]*len(ASSETS),
                        constraints=({"type": "eq", "fun": lambda w: w.sum() - 1},))
w_tan_lo = opt.x

print(pd.DataFrame({"GMV": w_gmv, "Tangencia (short OK)": w_tan,
                    "Tangencia (long-only)": w_tan_lo}, index=ASSETS).round(4))
         GMV  Tangencia (short OK)  Tangencia (long-only)
AAPL  0.0698                0.2139                 0.1997
JPM   0.1841                0.3067                 0.2467
WMT   0.1883                0.1809                 0.1756
XOM   0.0723               -0.0966                 0.0000
TSLA -0.0159                0.0207                 0.0253
GLD   0.5013                0.3745                 0.3526
# --- Cartera market-neutral del Workshop (Parte II) --------------------------
long_leg  = 0.5*r["AAPL"] + 0.5*r["WMT"]              # pata larga
beta_long = 0.5*betas["AAPL"] + 0.5*betas["WMT"]      # su beta

# Vendemos en corto SPY por un monto igual a beta_long; el dinero de la venta
# en corto se invierte a la tasa libre de riesgo (por eso el tercer término).
mkt_neutral = long_leg - beta_long*r["SPY"] + beta_long*rf_m

print("Razón de cobertura (corto en SPY): %.4f" % beta_long)
Razón de cobertura (corto en SPY): 0.7737
# --- Funciones de desempeño y de modelos de factores -------------------------
def performance(ret_series, name, benchmark=None, rf=None, mkt=None):
    """Reporte completo de desempeño ajustado por riesgo de una serie cc mensual."""
    benchmark = r["SPY"] if benchmark is None else benchmark
    rf  = rf_m if rf is None else rf
    mkt = mkt_prem if mkt is None else mkt

    ex = ret_series - rf                                    # rendimientos premio
    d  = pd.DataFrame({"y": ex.values, "x": mkt.values}, index=mkt.index)
    m  = smf.ols("y ~ x", data=d).fit()                     # regresión CAPM
    alpha_m, beta = m.params.iloc[0], m.params.iloc[1]
    active = ret_series - benchmark                         # rendimiento activo

    sharpe   = ex.mean()/ex.std()*np.sqrt(12)
    sharpe_m = (benchmark - rf).mean()/(benchmark - rf).std()*np.sqrt(12)
    sd_mkt_a = (benchmark - rf).std()*np.sqrt(12)

    return pd.Series({
        "Rend. anual":      np.exp(12*ret_series.mean()) - 1,
        "Vol. anual":       ret_series.std()*np.sqrt(12),
        "Beta":             beta,
        "Sharpe":           sharpe,
        "Treynor":          ex.mean()*12/beta,
        "Alfa Jensen (an)": alpha_m*12,
        "p-value del alfa": m.pvalues.iloc[0],
        "Information ratio": np.nan if active.std() == 0 else active.mean()/active.std()*np.sqrt(12),
        "Tracking error":   active.std()*np.sqrt(12),
        "M2":               (sharpe - sharpe_m)*sd_mkt_a}, name=name)


def factor_model(ret_series, factors=("Mkt_RF",), rf=None, data=None):
    """Regresión OLS del rendimiento premio de una serie sobre un set de factores."""
    rf   = rf_m if rf is None else rf
    data = ff if data is None else data
    d = pd.DataFrame({"y": ret_series - rf})
    d = d.join(data[list(factors)], how="inner").dropna()
    return smf.ols("y ~ " + " + ".join(factors), data=d).fit()


def model_row(m, name):
    """Resume un modelo de factores en un renglón comparable."""
    p = m.params
    return pd.Series({
        "alfa (anual)":  p.iloc[0]*12,
        "p-value alfa":  m.pvalues.iloc[0],
        "beta Mkt":  p.get("Mkt_RF", np.nan),
        "beta SMB":  p.get("SMB", np.nan),
        "beta HML":  p.get("HML", np.nan),
        "beta RMW":  p.get("RMW", np.nan),
        "beta CMA":  p.get("CMA", np.nan),
        "R2":        m.rsquared,
        "R2 ajustada": m.rsquared_adj}, name=name)

print("Setup listo.")
Setup listo.

3 CHALLENGE 1 — Solución

3.1 (a) HPR de los 8 instrumentos, por dos caminos

La idea. El Holding Period Return (HPR) es simplemente “cuánto ganó mi dinero desde que entré hasta que salí”, sin importar cuánto tiempo pasó. Se puede calcular de dos formas que deben dar el mismo número:

HPR=\frac{P_{final}}{P_{inicial}}-1 \qquad\text{y}\qquad HPR=e^{\sum_{t=1}^{N} r_t}-1

La segunda funciona porque los rendimientos continuamente compuestos (cc) se suman en el tiempo. Si sumas los 139 rendimientos cc mensuales obtienes el rendimiento cc de todo el periodo, y exponenciarlo te devuelve al rendimiento simple acumulado. Que ambos caminos coincidan es tu prueba de que no te equivocaste al construir los rendimientos.

# Camino 1: directo de los precios (último precio entre primer precio, menos 1)
hpr_precios = prices.iloc[-1] / prices.iloc[0] - 1

# Camino 2: exponencial de la SUMA de los rendimientos cc
hpr_cc = np.exp(r.sum()) - 1

tabla_c1a = pd.DataFrame({
    "HPR con precios":      hpr_precios,
    "HPR con cc":           hpr_cc,
    "Diferencia":           hpr_precios - hpr_cc,
    "$1 se convirtió en":   1 + hpr_precios
}).sort_values("HPR con precios", ascending=False)

tabla_c1a.round(6)
        HPR con precios  HPR con cc  Diferencia  $1 se convirtió en
Ticker                                                             
TSLA            19.9889     27.3665     -7.3776             20.9889
AAPL            11.6473     10.8469      0.8004             12.6473
JPM              6.6746      6.1090      0.5656              7.6746
WMT              3.8092      3.8983     -0.0891              4.8092
SPY              3.4000      3.3985      0.0015              4.4000
GLD              2.2712      2.2434      0.0278              3.2712
XOM              1.7313      1.4023      0.3289              2.7313
HYG              0.6364      0.6389     -0.0025              1.6364
NoteUn detalle técnico que vale la pena entender

Las dos columnas no coinciden exactamente al último decimal: la diferencia es del orden de 10^{-15}. Eso no es un error de finanzas, es error de redondeo de la computadora (los float de 64 bits guardan ~16 dígitos). Si tu diferencia es del orden de 10^{-15}, tu código está bien. Si es del orden de 10^{-2}, tienes un problema real: casi siempre es que el primer renglón de r se perdió con el .dropna() y estás comparando periodos distintos.

Interpretación. El HPR es enorme para TSLA y pequeño para HYG, pero no son comparables entre sí sin más contexto. Fíjate en la última columna: te dice en cuánto se convirtió un dólar invertido al inicio de la muestra. Ésa es la lectura honesta del HPR: acumulación de riqueza, nada más.

3.2 (b) ¿Quién ganó más, y qué esconde el HPR?

ganador = tabla_c1a.index[0]
print("Instrumento con el mayor HPR: %s" % ganador)
Instrumento con el mayor HPR: TSLA
print("HPR = %.2f  (es decir, $1 se convirtió en $%.2f)"
      % (tabla_c1a.loc[ganador, "HPR con precios"],
         tabla_c1a.loc[ganador, "$1 se convirtió en"]))
HPR = 19.99  (es decir, $1 se convirtió en $20.99)
print()
# Ahora pongamos ese HPR al lado de lo que costó obtenerlo
contexto = pd.DataFrame({
    "HPR":              hpr_precios,
    "Vol. anual":       vol_a,
    "Peor mes":         r.min(),
    "Máx. drawdown":    (np.exp(r.cumsum())/np.exp(r.cumsum()).cummax() - 1).min()
}).sort_values("HPR", ascending=False)

contexto.round(4)
           HPR  Vol. anual  Peor mes  Máx. drawdown
Ticker                                             
TSLA   19.9889      0.5613   -0.4578        -0.6772
AAPL   11.6473      0.2670   -0.1999        -0.3046
JPM     6.6746      0.2384   -0.2544        -0.3711
WMT     3.8092      0.1903   -0.1698        -0.3135
SPY     3.4000      0.1504   -0.1334        -0.2393
GLD     2.2712      0.1543   -0.1243        -0.2385
XOM     1.7313      0.2657   -0.3036        -0.5771
HYG     0.6364      0.0729   -0.1057        -0.1525

Interpretación (ésta es la parte que se califica).

El instrumento con el mayor HPR es, con enorme diferencia, TSLA. ¿Era el que hubieras querido tener? La respuesta honesta es: sólo si hubieras podido aguantarlo, y eso es exactamente lo que el HPR no te dice. El HPR esconde por lo menos cuatro cosas:

  1. El riesgo. TSLA tiene una volatilidad anual de más del 50%, cerca de cuatro veces la del mercado. El HPR es un número de ganancia sin denominador de riesgo. Es como decir “gané la carrera” sin mencionar que ibas en moto y los demás a pie.

  2. El camino (el drawdown). Mira la última columna. Llegar a ese HPR implicó pasar por caídas de más del 50% desde el máximo previo. Un inversionista real —o peor, un hedge fund con inversionistas que pueden redimir— probablemente habría vendido en el fondo. El HPR supone que aguantaste todo el camino sin moverte, y eso casi nadie lo hace.

  3. El horizonte. El HPR no está anualizado. Un HPR de 100% en un año y un HPR de 100% en diez años son cosas radicalmente distintas, y el número es el mismo. Para comparar hay que anualizar (media geométrica).

  4. El punto de entrada. El HPR depende por completo de las dos fechas que elegiste. Cambia el mes inicial por uno anterior o posterior y el ranking se puede reacomodar. Esta fragilidad tiene nombre en la industria de hedge funds: cherry-picking de la fecha de inicio, y es la primera cosa que hay que revisar en el material de marketing de un fondo.

Conexión con el curso. Éste es, en miniatura, el problema central del módulo: la industria vende rendimientos acumulados y el trabajo del analista es convertirlos en rendimientos ajustados por riesgo. Todo lo que hacemos en las Partes III y IV es ponerle denominador a este numerador.

3.3 (c) Porcentaje de meses negativos

La idea. El HPR mide la magnitud del resultado. El porcentaje de meses negativos mide la frecuencia con la que perdiste. Son dos preguntas distintas y no tienen por qué dar el mismo ranking.

# (r < 0) devuelve True/False; .mean() sobre booleanos da la PROPORCIÓN de True
meses_negativos = (r < 0).mean()

tabla_c1c = pd.DataFrame({
    "% meses negativos": meses_negativos,
    "HPR":               hpr_precios,
    "Vol. anual":        vol_a})

# rank(): 1 = el mejor. Para "% meses negativos", menor es mejor (ascending=True)
tabla_c1c["Rank por % neg."] = tabla_c1c["% meses negativos"].rank(ascending=True)
tabla_c1c["Rank por HPR"]    = tabla_c1c["HPR"].rank(ascending=False)
tabla_c1c["Diferencia de rank"] = tabla_c1c["Rank por HPR"] - tabla_c1c["Rank por % neg."]

print(tabla_c1c.sort_values("% meses negativos").round(4))
        % meses negativos     HPR  ...  Rank por HPR  Diferencia de rank
Ticker                             ...                                  
SPY                0.3116  3.4000  ...        5.0000              4.0000
HYG                0.3478  0.6364  ...        8.0000              6.0000
WMT                0.4130  3.8092  ...        4.0000              1.0000
AAPL               0.4275 11.6473  ...        2.0000             -2.5000
JPM                0.4275  6.6746  ...        3.0000             -1.5000
TSLA               0.4710 19.9889  ...        1.0000             -5.5000
XOM                0.4710  1.7313  ...        7.0000              0.5000
GLD                0.4855  2.2712  ...        6.0000             -2.0000

[8 rows x 6 columns]
# Correlación de Spearman: mide qué tanto coinciden DOS RANKINGS (no dos niveles)
rho = tabla_c1c["Rank por % neg."].corr(tabla_c1c["Rank por HPR"], method="spearman")
print("\nCorrelación de rangos (Spearman) entre ambos rankings: %.4f" % rho)

Correlación de rangos (Spearman) entre ambos rankings: -0.1566

Interpretación.

Los dos rankings no coinciden, y la correlación de rangos es baja. El caso extremo es TSLA: es el número 1 por HPR y está en la mitad baja de la tabla por frecuencia de meses negativos. Es decir, TSLA pierde tan seguido como los demás — lo que la hace distinta es el tamaño de los meses buenos, no la frecuencia.

El caso inverso es SPY: es el instrumento que menos meses en rojo tiene, y sin embargo su HPR está a la mitad de la tabla. Y GLD es el que más meses negativos acumula, pero como sus caídas son chicas, no termina último en riqueza acumulada.

La lección. La frecuencia de pérdidas y la magnitud de las pérdidas son dimensiones independientes del riesgo. Y eso importa muchísimo en este curso, porque hay estrategias de hedge funds diseñadas específicamente para verse bien en una de las dos:

  • Una estrategia de vender volatilidad (o carry) gana casi todos los meses y pierde catastróficamente de vez en cuando: se ve excelente en “% meses positivos” y es una bomba de tiempo. El ejemplo clásico es “recoger monedas frente a una aplanadora”.
  • Una estrategia de momentum o de tail hedge pierde poquito casi siempre y gana mucho en las crisis: se ve pésima en frecuencia y puede ser un gran negocio.

Por eso en la Parte IV no medimos riesgo con la volatilidad sola, sino con asimetría, VaR y Expected Shortfall, que sí distinguen entre estos dos perfiles.

4 CHALLENGE 2 — Solución

4.1 (a) Cartera 50% TSLA / 50% HYG por los dos métodos

La idea. Éste es el ejercicio fundacional de Markowitz. Hay que ver con los propios ojos dos hechos:

  • El rendimiento esperado de una cartera es el promedio ponderado de los rendimientos esperados.
  • La varianza de la cartera no es el promedio ponderado de las varianzas: le sobra (o le falta) el término de covarianza.

E[R_P]=w_1E[R_1]+w_2E[R_2] \qquad VAR(R_P)=w_1^2\sigma_1^2+w_2^2\sigma_2^2+2w_1w_2\,\rho_{12}\sigma_1\sigma_2

Escribimos una función general para no repetir código, porque la vamos a usar tres veces.

def analiza_par(a, b, wa=0.50, verbose=True):
    """
    Analiza una cartera de dos activos.
    a, b : nombres de los tickers
    wa   : peso del PRIMER activo (el segundo recibe 1-wa)
    Devuelve un diccionario con los resultados y (si verbose) los imprime.
    """
    w  = np.array([wa, 1 - wa])                 # vector de pesos
    mu = ER_a[[a, b]].values                    # vector de E[R] anuales
    S  = (12 * r[[a, b]].cov()).values          # matriz var-cov ANUAL (2x2)

    # --- Rendimiento esperado -------------------------------------------------
    ER_suma   = w[0]*mu[0] + w[1]*mu[1]         # Método 1: suma de productos
    ER_matriz = w @ mu                          # Método 2: álgebra matricial

    # --- Varianza -------------------------------------------------------------
    # Método 1: la fórmula "a mano". Fíjate en el 2*w1*w2*COV: ése es el
    # término de diversificación, y es el único que puede ser negativo.
    var_suma   = w[0]**2*S[0,0] + w[1]**2*S[1,1] + 2*w[0]*w[1]*S[0,1]
    # Método 2: w' * Sigma * w. Exactamente lo mismo, pero funciona con N activos.
    var_matriz = w @ S @ w

    sd_cartera = np.sqrt(var_matriz)
    sd_a, sd_b = np.sqrt(S[0,0]), np.sqrt(S[1,1])
    sd_promedio_ponderado = w[0]*sd_a + w[1]*sd_b     # el "benchmark sin diversificación"
    ahorro = sd_promedio_ponderado - sd_cartera        # lo que ganó la diversificación
    rho = r[[a, b]].corr().iloc[0, 1]

    if verbose:
        print("=" * 62)
        print(f"CARTERA {wa:.0%} {a} / {1-wa:.0%} {b}")
        print("=" * 62)
        print("E[R] anual  — suma de productos : %.4f" % ER_suma)
        print("E[R] anual  — álgebra matricial : %.4f" % ER_matriz)
        print("Varianza    — suma de productos : %.6f" % var_suma)
        print("Varianza    — álgebra matricial : %.6f" % var_matriz)
        print("Volatilidad anual de la cartera : %.4f" % sd_cartera)
        print("-" * 62)
        print("Volatilidad de %-5s sola        : %.4f" % (a, sd_a))
        print("Volatilidad de %-5s sola        : %.4f" % (b, sd_b))
        print("Promedio ponderado de las vols. : %.4f" % sd_promedio_ponderado)
        print("AHORRO POR DIVERSIFICACIÓN      : %.4f  (%.2f puntos porcentuales)"
              % (ahorro, ahorro*100))
        print("Correlación entre %s y %s        : %.4f" % (a, b, rho))
        print()

    return dict(ER=ER_matriz, sd=sd_cartera, sd_a=sd_a, sd_b=sd_b,
                sd_prom=sd_promedio_ponderado, ahorro=ahorro, rho=rho, S=S, mu=mu)

res_th = analiza_par("TSLA", "HYG")
==============================================================
CARTERA 50% TSLA / 50% HYG
==============================================================
E[R] anual  — suma de productos : 0.1908
E[R] anual  — álgebra matricial : 0.1908
Varianza    — suma de productos : 0.086324
Varianza    — álgebra matricial : 0.086324
Volatilidad anual de la cartera : 0.2938
--------------------------------------------------------------
Volatilidad de TSLA  sola        : 0.5613
Volatilidad de HYG   sola        : 0.0729
Promedio ponderado de las vols. : 0.3171
AHORRO POR DIVERSIFICACIÓN      : 0.0233  (2.33 puntos porcentuales)
Correlación entre TSLA y HYG        : 0.3043

Interpretación.

Lo primero: los dos métodos dan exactamente el mismo número, tanto para el rendimiento como para la varianza. No es una casualidad: la multiplicación de matrices es una máquina de calcular sumas de productos. La razón por la que insistimos en el método matricial es puramente práctica: con 2 activos la fórmula a mano tiene 3 términos, con 6 activos tiene 21, y con 100 activos tiene 5,050. La expresión \mathbf{w}'\boldsymbol{\Sigma}\mathbf{w} es una sola línea de código sin importar el tamaño.

Lo segundo, y más importante: la volatilidad de la cartera es menor que el promedio ponderado de las volatilidades individuales. Y esa reducción no costó nada de rendimiento esperado —el E[R] es exactamente el promedio ponderado—. Esto es lo que la gente llama “la única comida gratis en finanzas”.

4.2 (b) ¿Cuántos puntos porcentuales ahorró la diversificación?

print("Volatilidad si NO hubiera diversificación (prom. ponderado): %.4f"
      % res_th["sd_prom"])
Volatilidad si NO hubiera diversificación (prom. ponderado): 0.3171
print("Volatilidad real de la cartera 50/50 TSLA-HYG              : %.4f"
      % res_th["sd"])
Volatilidad real de la cartera 50/50 TSLA-HYG              : 0.2938
print("Ahorro                                                     : %.4f"
      % res_th["ahorro"])
Ahorro                                                     : 0.0233
print("Es decir, %.2f puntos porcentuales de volatilidad anual, o una"
      % (res_th["ahorro"]*100))
Es decir, 2.33 puntos porcentuales de volatilidad anual, o una
print("reducción relativa de %.1f%% respecto al promedio ponderado."
      % (100*res_th["ahorro"]/res_th["sd_prom"]))
reducción relativa de 7.3% respecto al promedio ponderado.

Interpretación. El ahorro es de unos pocos puntos porcentuales. Es real, pero es decepcionantemente pequeño, y la razón está en la estructura de la cartera: TSLA es tan volátil comparada con HYG que la cartera 50/50 está, en términos de riesgo, dominada casi por completo por TSLA.

Éste es un punto conceptual que suele costar trabajo:

ImportantRepartir el dinero al 50/50 no es repartir el riesgo al 50/50

Un peso de 50% en TSLA y 50% en HYG no es una cartera balanceada. La contribución de cada activo a la varianza es proporcional a w_i \sigma_i, no a w_i. Con TSLA a ~55% de volatilidad y HYG a ~8%, la contribución de TSLA al riesgo total es del orden del 95%.

Ésta es exactamente la observación que da origen a una familia entera de estrategias de hedge funds: risk parity, donde los pesos se eligen para igualar la contribución al riesgo, no el dinero invertido.

4.3 (c) Comparación con la cartera 50% AAPL / 50% JPM

res_aj = analiza_par("AAPL", "JPM")
==============================================================
CARTERA 50% AAPL / 50% JPM
==============================================================
E[R] anual  — suma de productos : 0.2129
E[R] anual  — álgebra matricial : 0.2129
Varianza    — suma de productos : 0.040734
Varianza    — álgebra matricial : 0.040734
Volatilidad anual de la cartera : 0.2018
--------------------------------------------------------------
Volatilidad de AAPL  sola        : 0.2670
Volatilidad de JPM   sola        : 0.2384
Promedio ponderado de las vols. : 0.2527
AHORRO POR DIVERSIFICACIÓN      : 0.0509  (5.09 puntos porcentuales)
Correlación entre AAPL y JPM        : 0.2733
comparacion = pd.DataFrame({
    "TSLA / HYG": [res_th["rho"], res_th["sd_prom"], res_th["sd"], res_th["ahorro"],
                   res_th["ahorro"]/res_th["sd_prom"]],
    "AAPL / JPM": [res_aj["rho"], res_aj["sd_prom"], res_aj["sd"], res_aj["ahorro"],
                   res_aj["ahorro"]/res_aj["sd_prom"]]},
    index=["Correlación", "Vol. si no diversificara", "Vol. de la cartera",
           "Ahorro absoluto (pp)", "Ahorro relativo (%)"])
comparacion.round(4)
                          TSLA / HYG  AAPL / JPM
Correlación                   0.3043      0.2733
Vol. si no diversificara      0.3171      0.2527
Vol. de la cartera            0.2938      0.2018
Ahorro absoluto (pp)          0.0233      0.0509
Ahorro relativo (%)           0.0735      0.2014

Interpretación — y aquí hay una trampa que hay que ver.

A primera vista el resultado parece contradictorio: la cartera AAPL/JPM tiene la correlación más alta de las dos y, aun así, puede mostrar un ahorro absoluto mayor o similar. ¿No debería diversificar más la pareja menos correlacionada?

La respuesta es que el ahorro depende de dos cosas, no de una:

  1. La correlación (menor correlación → más diversificación), y
  2. Qué tan parecidas son las volatilidades individuales.

La fórmula lo dice: cuando \rho = 1 el ahorro es exactamente cero, sin importar las volatilidades. Pero para \rho < 1, el beneficio se maximiza cuando los dos activos tienen volatilidades similares, porque entonces ninguno domina al otro y el efecto de promediar es máximo. AAPL y JPM tienen volatilidades parecidas (ambas entre 22% y 30%); TSLA y HYG son incomparables.

TipCómo comparar diversificación entre carteras de escalas distintas

Comparar el ahorro en puntos porcentuales es engañoso cuando las carteras tienen niveles de riesgo muy distintos. La medida limpia es el ahorro relativo (última fila de la tabla), o equivalentemente el ratio de diversificación:

DR=\frac{\sum_i w_i\sigma_i}{\sigma_P}

Un DR de 1.00 significa “no diversifiqué nada”; entre más arriba de 1, mejor. Éste sí es comparable entre carteras.

# El ratio de diversificación, que sí es comparable entre carteras
for nombre, res in [("TSLA/HYG", res_th), ("AAPL/JPM", res_aj)]:
    print("Ratio de diversificación %-10s: %.4f   (correlación %.3f)"
          % (nombre, res["sd_prom"]/res["sd"], res["rho"]))
Ratio de diversificación TSLA/HYG  : 1.0793   (correlación 0.304)
Ratio de diversificación AAPL/JPM  : 1.2521   (correlación 0.273)

Conexión con la matriz de correlaciones del Workshop. Si vuelves a ver el heatmap, GLD es el que tiene correlaciones cercanas a cero con todo lo demás. Por eso, si quisieras el máximo beneficio de diversificación en este universo, la pareja a construir no es ninguna de estas dos: es una que incluya GLD con una acción de volatilidad comparable. La diversificación se compra con correlación baja, no con número de activos.

4.4 (d) El peso que minimiza la varianza (solución analítica)

La idea. Queremos el w que minimiza

VAR(R_P)=w^2\sigma_1^2+(1-w)^2\sigma_2^2+2w(1-w)COV_{12}

Derivamos respecto a w e igualamos a cero:

\frac{\partial VAR}{\partial w}=2w\sigma_1^2-2(1-w)\sigma_2^2+2(1-2w)COV_{12}=0

Despejando:

\boxed{\;w^{*}=\frac{\sigma_2^2-COV_{12}}{\sigma_1^2+\sigma_2^2-2COV_{12}}\;}

Fíjate en la estructura: el numerador compara la varianza del otro activo contra la covarianza. Si el activo 1 es muy volátil, \sigma_1^2 aparece sólo en el denominador y el peso w^* se hace chico. Tiene todo el sentido económico: la cartera de mínima varianza carga hacia el activo tranquilo.

S = res_th["S"]                      # matriz var-cov anual de TSLA (0) y HYG (1)
var1, var2, cov12 = S[0,0], S[1,1], S[0,1]

# --- Solución analítica -------------------------------------------------------
w_estrella = (var2 - cov12) / (var1 + var2 - 2*cov12)

# --- Verificación numérica con una malla de pesos ----------------------------
# Dejamos que w vaya de -0.5 a 1.5 para permitir ventas en corto: es importante,
# porque el óptimo podría estar FUERA del intervalo [0,1].
malla = np.linspace(-0.5, 1.5, 20001)
var_malla = malla**2*var1 + (1-malla)**2*var2 + 2*malla*(1-malla)*cov12
w_numerico = malla[var_malla.argmin()]

print("w* en TSLA — solución analítica : %.4f" % w_estrella)
w* en TSLA — solución analítica : -0.0242
print("w* en TSLA — búsqueda numérica  : %.4f" % w_numerico)
w* en TSLA — búsqueda numérica  : -0.0242
print("Volatilidad mínima alcanzable   : %.4f" % np.sqrt(var_malla.min()))
Volatilidad mínima alcanzable   : 0.0717
print("Volatilidad de la cartera 50/50 : %.4f" % res_th["sd"])
Volatilidad de la cartera 50/50 : 0.2938
print("Volatilidad de HYG sola         : %.4f" % np.sqrt(var2))
Volatilidad de HYG sola         : 0.0729
# Visualización: la curva de varianza como función del peso
plt.figure(figsize=(9, 5))
<Figure size 900x500 with 0 Axes>
plt.plot(malla, np.sqrt(var_malla), lw=2)
[<matplotlib.lines.Line2D object at 0x000002E055DDF620>]
plt.axvline(w_estrella, color="red", ls="--",
            label=f"w* = {w_estrella:.4f} (mínima varianza)")
<matplotlib.lines.Line2D object at 0x000002E055DDF380>
plt.axvline(0.50, color="grey", ls=":", label="w = 0.50 (la cartera del inciso a)")
<matplotlib.lines.Line2D object at 0x000002E055DDF770>
plt.xlabel("Peso en TSLA")
Text(0.5, 0, 'Peso en TSLA')
plt.ylabel("Volatilidad anual de la cartera")
Text(0, 0.5, 'Volatilidad anual de la cartera')
plt.title("La volatilidad de la cartera como función del peso en TSLA")
Text(0.5, 1.0, 'La volatilidad de la cartera como función del peso en TSLA')
plt.legend(); plt.grid(alpha=0.3)
<matplotlib.legend.Legend object at 0x000002E055DDF8C0>
plt.show()

Interpretación.

La solución analítica y la numérica coinciden hasta el cuarto decimal: la matemática y la computadora están de acuerdo. Éste es un hábito que vale la pena adquirir — siempre que puedas, verifica una fórmula cerrada con una búsqueda de fuerza bruta, porque es la forma más barata de detectar un error de álgebra.

Ahora la lectura económica, que es lo interesante:

  • El peso óptimo en TSLA es casi cero, y de hecho ligeramente negativo. La cartera de mínima varianza dice: “pon prácticamente todo en HYG y, si te dejan, vende TSLA en corto un poquito”.

  • ¿Por qué un corto? Porque TSLA y HYG están positivamente correlacionadas. Cuando dos activos se mueven juntos, la única forma de cancelar movimiento es poner uno con signo contrario. Ese peso negativo es, literalmente, una cobertura, y es la primera vez en el Workshop que la matemática la produce sola, sin que se la pidamos.

  • La volatilidad mínima alcanzable es menor que la de HYG sola, que es el activo menos volátil de los dos. Éste es el resultado de Markowitz en su forma más pura: una cartera puede ser menos riesgosa que cualquiera de sus componentes.

  • Compara con la cartera del inciso (a): pasar de w=0.50 a w=w^* reduce la volatilidad de ~31% a ~8%. La gráfica muestra que la curva es muy plana cerca del mínimo y muy empinada del lado derecho: equivocarte poniendo de más en el activo volátil se paga carísimo; equivocarte poniendo de menos casi no cuesta.

NotePor qué este inciso es el corazón del curso

Si dejas w libre de signo, el problema de mínima varianza produce solo una posición corta. Un hedge fund es exactamente eso: Markowitz sin la restricción w_i \geq 0. Todo lo “exótico” que veremos en el módulo —long/short equity, pairs trading, market neutral, arbitraje estadístico— sale de quitarle esa restricción a un problema que ya conoces.

5 CHALLENGE 3 — Solución

5.1 (a) Agregar HYG al universo: el optimizador se vuelve loco

La idea. Vamos a repetir exactamente los mismos cálculos, pero con 7 activos en vez de 6. HYG es un ETF de bonos corporativos de alto rendimiento: baja volatilidad, rendimiento moderado, y correlación media con las acciones. Suena inofensivo. No lo es.

# --- Universo ampliado --------------------------------------------------------
ASSETS7 = ASSETS + ["HYG"]

mu7  = np.exp(12 * r[ASSETS7].mean()).values - 1     # E[R] anuales
COV7 = (12 * r[ASSETS7].cov()).values                # matriz var-cov anual (7x7)
o7   = np.ones(len(ASSETS7))
Si7  = np.linalg.inv(COV7)

# --- GMV y tangencia con las mismas fórmulas cerradas -------------------------
w_gmv7 = Si7 @ o7 / (o7 @ Si7 @ o7)
z7     = Si7 @ (mu7 - RF*o7)
w_tan7 = z7 / (o7 @ z7)

comp_pesos = pd.DataFrame({
    "GMV (6 activos)":       pd.Series(w_gmv,  index=ASSETS),
    "GMV (7, con HYG)":      pd.Series(w_gmv7, index=ASSETS7),
    "Tangencia (6 activos)": pd.Series(w_tan,  index=ASSETS),
    "Tangencia (7, con HYG)": pd.Series(w_tan7, index=ASSETS7)})
comp_pesos.round(4)
      GMV (6 activos)  ...  Tangencia (7, con HYG)
AAPL           0.0698  ...                 -0.8658
GLD            0.5013  ...                 -1.4716
HYG               NaN  ...                  5.1437
JPM            0.1841  ...                 -1.3832
TSLA          -0.0159  ...                 -0.0568
WMT            0.1883  ...                 -0.2900
XOM            0.0723  ...                 -0.0763

[7 rows x 4 columns]
def resumen_cartera(w, mu, S, rf=RF):
    """Rendimiento, volatilidad, Sharpe y apalancamiento bruto de una cartera."""
    ER = w @ mu
    sd = np.sqrt(w @ S @ w)
    return pd.Series({"E[R] anual": ER, "Vol. anual": sd, "Sharpe": (ER - rf)/sd,
                      "Suma de pesos": w.sum(),
                      "Apalancamiento bruto (suma |w|)": np.abs(w).sum(),
                      "Posición larga máxima": w.max(),
                      "Posición corta máxima": w.min()})

pd.DataFrame({
    "GMV 6":       resumen_cartera(w_gmv,  mu_a, COV_A),
    "GMV 7":       resumen_cartera(w_gmv7, mu7,  COV7),
    "Tangencia 6": resumen_cartera(w_tan,  mu_a, COV_A),
    "Tangencia 7": resumen_cartera(w_tan7, mu7,  COV7)}).round(4)
                                  GMV 6   GMV 7  Tangencia 6  Tangencia 7
E[R] anual                       0.1333  0.0181       0.1748      -0.4658
Vol. anual                       0.1084  0.0631       0.1293       0.3435
Sharpe                           0.9067 -0.2678       1.0815      -1.4581
Suma de pesos                    1.0000  1.0000       1.0000       1.0000
Apalancamiento bruto (suma |w|)  1.0317  1.4603       1.1932       9.2874
Posición larga máxima            0.5013  1.1452       0.3745       5.1437
Posición corta máxima           -0.0159 -0.1110      -0.0966      -1.4716
# ¿Qué tiene HYG que provoque esto? Veamos sus estadísticos y correlaciones.
print("HYG:  E[R] anual = %.4f   |   Vol. anual = %.4f   |   RF = %.4f"
      % (mu7[-1], np.sqrt(COV7[-1,-1]), RF))
HYG:  E[R] anual = 0.0439   |   Vol. anual = 0.0729   |   RF = 0.0350
print("Sharpe individual de HYG = %.4f" % ((mu7[-1] - RF)/np.sqrt(COV7[-1,-1])))
Sharpe individual de HYG = 0.1219
print("\nCorrelaciones de HYG con el resto del universo:")

Correlaciones de HYG con el resto del universo:
print(r[ASSETS7].corr()["HYG"].drop("HYG").round(3))
Ticker
AAPL   0.5100
JPM    0.6150
WMT    0.2090
XOM    0.4610
TSLA   0.3040
GLD    0.2310
Name: HYG, dtype: float64

Interpretación — ésta es de las respuestas más importantes del Workshop.

Qué pasó. La cartera GMV casi no se inmuta: sigue siendo una cartera sensata, con pesos moderados, y ahora carga fuerte en HYG (lo cual es correcto: HYG es el activo de menor volatilidad del universo, y minimizar varianza es exactamente para lo que sirve). Su volatilidad baja.

La cartera de tangencia, en cambio, explota. Aparecen pesos larguísimos en algunas acciones financiados por un corto gigantesco en HYG (del orden de −600%), y el apalancamiento bruto (la suma de los valores absolutos de los pesos) pasa de ≈1 a más de 10. El “rendimiento esperado” que reporta esa cartera es absurdo — del orden del 90% anual — y su volatilidad también.

Por qué pasó. No es un bug. El optimizador está haciendo exactamente lo que le pediste. La fórmula de tangencia es

\mathbf{w}_{TAN}\propto\boldsymbol{\Sigma}^{-1}(\boldsymbol{\mu}-R_f\mathbf{1})

y hay dos ingredientes que se combinan mal:

  1. HYG está muy correlacionado con las acciones (≈0.5) pero es mucho menos volátil. Para el álgebra, eso lo convierte en un instrumento casi redundante: “HYG es aproximadamente media acción”. Y un activo casi redundante hace que la matriz de covarianzas se acerque a ser singular (mal condicionada). Cuando inviertes una matriz casi singular, \boldsymbol{\Sigma}^{-1} tiene entradas enormes, y esas entradas enormes se traducen directamente en pesos enormes.

  2. El premio esperado de HYG es bajísimo (su E[R] está apenas por encima de R_f). Entonces el optimizador concluye: “tengo un instrumento que se mueve como media acción, pero que casi no paga premio; véndelo en corto y usa el dinero para comprar más de las acciones que sí pagan”. Desde dentro del modelo es una deducción impecable.

Por qué es un desastre en la práctica. Porque los dos insumos de esa deducción son ruido. Recuerda el callout del Workshop: con 139 meses, el error estándar del rendimiento esperado anual de una acción con 30% de volatilidad es de ~8.8 puntos porcentuales. El optimizador trata una diferencia de 2 puntos entre dos activos como si fuera un hecho, cuando estadísticamente es indistinguible de cero. A esto se le llama, con toda justicia, “maximizador de errores” (error maximizer, Michaud 1989): la optimización media-varianza asigna los pesos más extremos precisamente a los activos cuyos parámetros están peor estimados.

Y lo peor: el resultado es frágil. Nota que la GMV —que no usa los rendimientos esperados— se portó bien, y la tangencia —que los usa— se descompuso. Ésa es la evidencia directa de dónde está el problema: no es la matriz de covarianzas, es el vector \mu.

Los tres remedios estándar (los tres aparecen en la industria de hedge funds):

Remedio Qué hace Costo
Restringir pesos (long-only, o topes por posición) Impide que el optimizador tome apuestas extremas Sesga la solución, pero suele mejorar el desempeño fuera de muestra
Shrinkage de la covarianza (Ledoit-Wolf) Jala \boldsymbol{\Sigma} hacia una matriz simple y bien condicionada Introduce sesgo a cambio de mucha menos varianza
Estimar \mu con un modelo (CAPM, factores, Black-Litterman) Sustituye la media histórica por algo menos ruidoso Depende de que el modelo sea razonable
WarningCómo se ve esto en un hedge fund real

Cuando un fondo te presenta una cartera “optimizada” con apalancamiento bruto de 10x, la pregunta correcta no es “¿cuál es tu Sharpe esperado?” sino “¿qué tan sensibles son estos pesos a tus estimaciones de rendimiento esperado?”. Si mover un \mu medio punto porcentual cambia los pesos radicalmente, no tienes una cartera: tienes una amplificación de ruido.

5.2 (b) La tangencia con R_f = 1\% y con R_f = 6\%

La idea. Aquí hay una intuición que casi todos los estudiantes tienen mal la primera vez. Sabemos que la tasa libre de riesgo mueve la posición del inversionista sobre la CML (más o menos apalancamiento). La pregunta es si además cambia la composición de la cartera riesgosa, y la respuesta es .

resultados_rf = {}
for rf in [0.01, 0.035, 0.06]:
    z_rf = Sinv @ (mu_a - rf*ones)          # vector de premios esperados, "girado" por Sigma^-1
    w_rf = z_rf / (ones @ z_rf)             # normalizamos para que los pesos sumen 1
    ER   = w_rf @ mu_a
    sd   = np.sqrt(w_rf @ COV_A @ w_rf)
    resultados_rf[f"RF = {rf:.1%}"] = pd.Series(
        dict(zip(ASSETS, w_rf)) | {"— E[R]": ER, "— Vol": sd, "— Sharpe": (ER - rf)/sd})

pd.DataFrame(resultados_rf).round(4)
          RF = 1.0%  RF = 3.5%  RF = 6.0%
AAPL         0.1847     0.2139     0.2631
JPM          0.2818     0.3067     0.3485
WMT          0.1824     0.1809     0.1783
XOM         -0.0623    -0.0966    -0.1543
TSLA         0.0133     0.0207     0.0331
GLD          0.4002     0.3745     0.3312
— E[R]       0.1664     0.1748     0.1890
— Vol        0.1221     0.1293     0.1438
— Sharpe     1.2811     1.0815     0.8969
# Para ver la lógica: el premio esperado de cada activo bajo cada RF
premios = pd.DataFrame({f"mu - {rf:.1%}": mu_a - rf for rf in [0.01, 0.035, 0.06]},
                       index=ASSETS)
premios["E[R] anual"] = mu_a
premios["Vol. anual"] = np.sqrt(np.diag(COV_A))
premios.round(4)
      mu - 1.0%  mu - 3.5%  mu - 6.0%  E[R] anual  Vol. anual
AAPL     0.2298     0.2048     0.1798      0.2398      0.2670
JPM      0.1760     0.1510     0.1260      0.1860      0.2384
WMT      0.1382     0.1132     0.0882      0.1482      0.1903
XOM      0.0692     0.0442     0.0192      0.0792      0.2657
TSLA     0.3276     0.3026     0.2776      0.3376      0.5613
GLD      0.0977     0.0727     0.0477      0.1077      0.1543

Interpretación.

Los pesos cambian, y cambian en una dirección perfectamente predecible: al subir R_f, la cartera de tangencia se vuelve más agresiva.

  • Con R_f=1\% la cartera carga más en GLD y en WMT — los activos tranquilos.
  • Con R_f=6\% los pesos en AAPL, JPM y TSLA suben mucho, y GLD (y a veces XOM) se van a negativo.

¿Por qué? La clave está en la segunda tabla. La fórmula de tangencia no depende de \mu, sino de \mu - R_f. Cuando R_f sube, todos los premios bajan en la misma cantidad absoluta, pero no en la misma cantidad relativa:

  • GLD tiene un E[R] de ~5-6%. Con R_f=1\% su premio es positivo y decente; con R_f=6\% su premio es cero o negativo. Un activo con premio negativo es un activo que, dentro del modelo, deberías vender en corto.
  • TSLA tiene un E[R] muy alto. Restarle 5 puntos porcentuales apenas lo mueve en términos relativos.

Dicho en una frase que puedes usar en el examen: la tasa libre de riesgo es la vara con la que se mide si vale la pena tomar riesgo. Al subir la vara, los activos de premio pequeño dejan de calificar, y el optimizador reasigna su dinero (y a veces se los vende en corto) hacia los de premio grande.

Nota además el tercer renglón del resumen: el Sharpe de la cartera de tangencia baja cuando R_f sube. Tiene sentido: si te pagan más por no tomar riesgo, el mejor negocio disponible tomando riesgo se ve relativamente menos atractivo. Geométricamente, la CML sale de un punto más alto en el eje vertical y por lo tanto es menos empinada.

NotePor qué esto importa para hedge funds

Muchas estrategias de hedge fund son, en el fondo, apuestas financiadas: carry trades, merger arbitrage, arbitraje de renta fija. Todas ellas ganan un diferencial sobre la tasa libre de riesgo. Un entorno de tasas altas no sólo les encarece el financiamiento: además eleva la barra que su estrategia tiene que superar para ser interesante. Por eso los ciclos de tasas reorganizan qué estrategias levantan capital.

5.3 (c) La frontera eficiente long-only

La idea. Ahora resolvemos, para una malla de rendimientos objetivo, el problema

\min_{\mathbf{w}}\ \mathbf{w}'\boldsymbol{\Sigma}\mathbf{w} \quad\text{s.a.}\quad \mathbf{w}'\boldsymbol{\mu}=\mu^{target},\quad \mathbf{w}'\mathbf{1}=1,\quad w_i\ge 0

Como ahora hay una desigualdad (w_i\ge0), ya no existe solución cerrada: hay que resolverlo numéricamente. Usamos SLSQP, que maneja restricciones de igualdad y cotas.

def frontera_long_only(mu, S, n_puntos=60):
    """
    Frontera eficiente con restricción de no ventas en corto.
    Para cada rendimiento objetivo, minimiza la varianza sujeto a:
      - los pesos suman 1
      - el rendimiento esperado es exactamente el objetivo
      - todos los pesos entre 0 y 1
    """
    n = len(mu)
    objetivos = np.linspace(mu.min(), mu.max(), n_puntos)
    vols, rets, pesos = [], [], []

    for m_obj in objetivos:
        restricciones = (
            {"type": "eq", "fun": lambda w: w.sum() - 1},          # suma de pesos = 1
            {"type": "eq", "fun": lambda w, m=m_obj: w @ mu - m})  # E[R] = objetivo
        res = optimize.minimize(lambda w: w @ S @ w,               # función objetivo: varianza
                                np.repeat(1/n, n),                 # punto inicial
                                method="SLSQP",
                                bounds=[(0, 1)]*n,                 # long-only
                                constraints=restricciones)
        if res.success:
            vols.append(np.sqrt(res.fun)); rets.append(m_obj); pesos.append(res.x)
    return np.array(vols), np.array(rets), np.array(pesos)

vol_lo, ret_lo, pesos_lo = frontera_long_only(mu_a, COV_A)
print("Puntos calculados en la frontera long-only:", len(ret_lo))
Puntos calculados en la frontera long-only: 60
# --- Frontera SIN restricción (short sales permitidas) ------------------------
# Usamos la separación en dos fondos: cualquier combinación de GMV y tangencia
# está sobre la frontera.
alphas   = np.linspace(-1.0, 2.5, 300)
frontera = np.array([a*w_gmv + (1-a)*w_tan for a in alphas])
f_ret = frontera @ mu_a
f_vol = np.sqrt(np.einsum("ij,jk,ik->i", frontera, COV_A, frontera))

r_gmv, s_gmv, _ = port_perf(w_gmv)
r_tan, s_tan, sh_tan = port_perf(w_tan)

plt.figure(figsize=(10, 6))
<Figure size 1000x600 with 0 Axes>
plt.plot(f_vol, f_ret, "b-", lw=2, label="Frontera SIN restricción (short sales OK)")
[<matplotlib.lines.Line2D object at 0x000002E05D196CF0>]
plt.plot(vol_lo, ret_lo, "g-", lw=3, label="Frontera LONG-ONLY")
[<matplotlib.lines.Line2D object at 0x000002E05D196E40>]
plt.scatter(np.sqrt(np.diag(COV_A)), mu_a, color="black", marker="x", s=70,
            label="activos individuales")
<matplotlib.collections.PathCollection object at 0x000002E05D196BA0>
for i, a in enumerate(ASSETS):
    plt.annotate(a, (np.sqrt(COV_A[i,i]), mu_a[i]), textcoords="offset points", xytext=(8,0))
Text(8, 0, 'AAPL')
Text(8, 0, 'JPM')
Text(8, 0, 'WMT')
Text(8, 0, 'XOM')
Text(8, 0, 'TSLA')
Text(8, 0, 'GLD')
plt.scatter(s_gmv, r_gmv, marker="D", s=130, color="green", zorder=5, label="GMV")
<matplotlib.collections.PathCollection object at 0x000002E05D3356D0>
plt.scatter(s_tan, r_tan, marker="*", s=300, color="red", zorder=5, label="Tangencia")
<matplotlib.collections.PathCollection object at 0x000002E05D335A90>
plt.xlabel("Riesgo anual (volatilidad)"); plt.ylabel("Rendimiento esperado anual")
Text(0.5, 0, 'Riesgo anual (volatilidad)')
Text(0, 0.5, 'Rendimiento esperado anual')
plt.title("Frontera eficiente: con y sin ventas en corto")
Text(0.5, 1.0, 'Frontera eficiente: con y sin ventas en corto')
plt.legend(); plt.grid(alpha=0.3)
<matplotlib.legend.Legend object at 0x000002E05D1970E0>
plt.xlim(0, max(np.sqrt(np.diag(COV_A)))*1.15)
(0.0, 0.6455041357139127)
plt.show()

# Comparación cuantitativa a niveles de rendimiento objetivo iguales
A = ones @ Sinv @ ones
B = ones @ Sinv @ mu_a
C = mu_a @ Sinv @ mu_a
D = A*C - B**2

filas = []
for m_obj in [0.10, 0.15, 0.20, 0.30]:
    # Frontera sin restricción: fórmula cerrada de la varianza mínima
    var_sin = (A*m_obj**2 - 2*B*m_obj + C)/D
    # Frontera long-only: hay que optimizar
    res = optimize.minimize(lambda w: w @ COV_A @ w, np.repeat(1/6, 6), method="SLSQP",
                            bounds=[(0,1)]*6,
                            constraints=({"type":"eq","fun":lambda w: w.sum()-1},
                                         {"type":"eq","fun":lambda w, m=m_obj: w@mu_a - m}))
    filas.append(pd.Series({"Vol. sin restricción": np.sqrt(var_sin),
                            "Vol. long-only": np.sqrt(res.fun) if res.success else np.nan,
                            "Costo de la restricción (pp)":
                                (np.sqrt(res.fun) - np.sqrt(var_sin))*100 if res.success else np.nan},
                           name=f"objetivo {m_obj:.0%}"))
pd.DataFrame(filas).round(4)
              Vol. sin restricción  ...  Costo de la restricción (pp)
objetivo 10%                0.1222  ...                        0.8739
objetivo 15%                0.1120  ...                        0.0001
objetivo 20%                0.1567  ...                        0.9977
objetivo 30%                0.3029  ...                       10.3580

[4 rows x 3 columns]

Interpretación.

¿Cuál domina? La frontera sin restricción domina, siempre y por construcción. La razón es puramente lógica, no empírica: el conjunto de carteras long-only es un subconjunto del conjunto de todas las carteras. Si le quitas opciones a un problema de minimización, el mínimo sólo puede quedarse igual o empeorar. Nunca mejorar.

¿Significa eso que las restricciones son malas? No, y aquí está la lección. Confundir estas dos cosas es el error más común del tema:

  • La frontera sin restricción domina dentro de la muestra, con los \mu y \boldsymbol{\Sigma} que estimaste. Es una afirmación sobre tu hoja de cálculo, no sobre el mundo.
  • Fuera de muestra —o sea, con dinero de verdad, en el futuro— la frontera long-only frecuentemente gana. Está demostrado empíricamente (Jagannathan y Ma, 2003, con un resultado precioso: la restricción w_i \geq 0 es matemáticamente equivalente a aplicar shrinkage a la matriz de covarianzas).

La intuición: la restricción actúa como un regularizador. Le impide al optimizador tomar las apuestas extremas que hacen daño en el inciso (a). Le pones una cota al daño que puede hacer el error de estimación.

Fíjate también en la tabla: cerca del centro de la frontera, el costo de la restricción es casi cero (centésimas de punto porcentual). Sólo en los extremos —cuando pides rendimientos muy altos— la restricción empieza a doler, porque para llegar ahí sin ventas en corto tienes que concentrarte en un solo activo. O sea: la restricción cuesta poco justo donde uno quiere estar, y cuesta mucho justo donde uno no debería estar.

5.4 (d) Un inversionista que quiere exactamente 10% de volatilidad

La idea. Con un activo libre de riesgo disponible, la frontera eficiente deja de ser la hipérbola y pasa a ser la CML, una recta. Sobre esa recta:

E[R_C]=R_f+y\left(E[R_{TAN}]-R_f\right) \qquad SD(R_C)=y\cdot SD(R_{TAN})

donde y es la fracción invertida en la cartera de tangencia. La segunda ecuación es la que resolvemos: como SD(R_C) es lineal en y, despejar es trivial.

objetivo_vol = 0.10

r_tan, s_tan, sh_tan = port_perf(w_tan)     # cartera de tangencia (6 activos, short OK)

y = objetivo_vol / s_tan                     # fracción en la cartera de tangencia
ER_c = RF + y*(r_tan - RF)                   # rendimiento esperado de la cartera completa

print("Cartera de tangencia:  E[R] = %.4f   SD = %.4f   Sharpe = %.4f"
      % (r_tan, s_tan, sh_tan))
Cartera de tangencia:  E[R] = 0.1748   SD = 0.1293   Sharpe = 1.0815
print("-"*66)
------------------------------------------------------------------
print("Objetivo de volatilidad                      : %.2f%%" % (objetivo_vol*100))
Objetivo de volatilidad                      : 10.00%
print("Fracción en la cartera de tangencia    (y)   : %.4f  (%.2f%%)" % (y, y*100))
Fracción en la cartera de tangencia    (y)   : 0.7736  (77.36%)
print("Fracción en el activo libre de riesgo (1-y)  : %.4f  (%.2f%%)" % (1-y, (1-y)*100))
Fracción en el activo libre de riesgo (1-y)  : 0.2264  (22.64%)
print("Rendimiento esperado de la cartera completa  : %.4f  (%.2f%%)" % (ER_c, ER_c*100))
Rendimiento esperado de la cartera completa  : 0.1431  (14.31%)
print("-"*66)
------------------------------------------------------------------
# Verificación por dos caminos: la volatilidad y el Sharpe deben cuadrar
print("Verificación — volatilidad y*SD(TAN)         : %.4f" % (y*s_tan))
Verificación — volatilidad y*SD(TAN)         : 0.1000
print("Verificación — Sharpe de la cartera completa : %.4f" % ((ER_c - RF)/objetivo_vol))
Verificación — Sharpe de la cartera completa : 1.0815
print("(debe ser IDÉNTICO al Sharpe de la tangencia: %.4f)" % sh_tan)
(debe ser IDÉNTICO al Sharpe de la tangencia: 1.0815)
# Y los pesos en pesos y centavos, para $1,000,000 de inversión
capital = 1_000_000
asignacion = pd.Series(y*w_tan, index=ASSETS)*capital
asignacion["Activo libre de riesgo"] = (1-y)*capital
print(asignacion.round(0))
AAPL                     165,472.0000
JPM                      237,260.0000
WMT                      139,906.0000
XOM                      -74,738.0000
TSLA                      15,996.0000
GLD                      289,701.0000
Activo libre de riesgo   226,403.0000
dtype: float64
print("\nTotal: %s" % f"{asignacion.sum():,.0f}")

Total: 1,000,000

Interpretación.

El inversionista pone alrededor de 60% en la cartera de tangencia y 40% en el activo libre de riesgo, y con eso obtiene un rendimiento esperado de aproximadamente 11.5% anual con exactamente 10% de volatilidad. Como y<1, es un inversionista conservador: está prestando al tipo libre de riesgo, no pidiendo prestado.

Los dos puntos que tienes que poder explicar:

  1. El Sharpe no cambió. La cartera completa tiene exactamente el mismo Sharpe que la cartera de tangencia. Mezclar con el activo libre de riesgo mueve tu punto a lo largo de la CML, pero no cambia su pendiente. Ésta es la razón matemática del teorema de separación: la decisión de qué activos riesgosos comprar (la tangencia) es la misma para todos, y es independiente de la decisión de cuánto riesgo tomar (y), que depende de tu aversión al riesgo.

  2. La misma aritmética funciona hacia arriba, y ahí está el negocio de los hedge funds. Si el inversionista quisiera 30% de volatilidad, y sería aproximadamente 1.8: pediría prestado 80% de su capital para invertir 180% en la cartera de tangencia. Eso es apalancamiento.

ImportantLa frase que hay que recordar del módulo entero

El apalancamiento no crea valor: te mueve sobre la misma recta.

Un hedge fund que reporta 25% de rendimiento con 3x de apalancamiento sobre una estrategia cuyo Sharpe es mediocre no ha hecho nada que tú no pudieras hacer tú mismo pidiendo prestado. Lo único que justifica una comisión de 2-and-20 es mover la recta hacia arriba —es decir, un Sharpe superior o un alfa genuino—, no moverse hacia la derecha sobre ella.

Corolario práctico: cuando veas el rendimiento de un fondo, divídelo siempre entre su volatilidad antes de opinar.

6 CHALLENGE 4 — Solución

6.1 (a) La regresión CAPM de JPM y de XOM, interpretada número por número

La idea. El modelo que estimamos es

\underbrace{(R_{i,t}-R_{f,t})}_{\text{premio del activo}} = b_0 + b_1\underbrace{(R_{M,t}-R_{f,t})}_{\text{premio del mercado}} + \varepsilon_t

y cada coeficiente tiene un nombre en finanzas: b_1 es beta (riesgo sistemático) y b_0 es el alfa de Jensen (lo que el mercado no explica).

def corre_capm(ticker, mostrar_resumen=True):
    """Corre la regresión CAPM de un ticker y devuelve el objeto de statsmodels."""
    d = pd.DataFrame({"premio_activo":  premR[ticker + "_prem"].values,
                      "premio_mercado": mkt_prem.values},
                     index=mkt_prem.index)
    m = smf.ols("premio_activo ~ premio_mercado", data=d).fit()
    if mostrar_resumen:
        print("=" * 74)
        print("REGRESIÓN CAPM DE", ticker)
        print("=" * 74)
        print(m.summary())
    return m

capm_jpm = corre_capm("JPM")
==========================================================================
REGRESIÓN CAPM DE JPM
==========================================================================
                            OLS Regression Results                            
==============================================================================
Dep. Variable:          premio_activo   R-squared:                       0.524
Model:                            OLS   Adj. R-squared:                  0.520
Method:                 Least Squares   F-statistic:                     149.5
Date:                Thu, 03 Sep 2026   Prob (F-statistic):           1.19e-23
Time:                        21:09:42   Log-Likelihood:                 225.39
No. Observations:                 138   AIC:                            -446.8
Df Residuals:                     136   BIC:                            -440.9
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
==================================================================================
                     coef    std err          t      P>|t|      [0.025      0.975]
----------------------------------------------------------------------------------
Intercept          0.0014      0.004      0.346      0.730      -0.007       0.010
premio_mercado     1.1049      0.090     12.225      0.000       0.926       1.284
==============================================================================
Omnibus:                        0.073   Durbin-Watson:                   2.022
Prob(Omnibus):                  0.964   Jarque-Bera (JB):                0.199
Skew:                          -0.040   Prob(JB):                        0.905
Kurtosis:                       2.832   Cond. No.                         22.3
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
capm_xom = corre_capm("XOM")
==========================================================================
REGRESIÓN CAPM DE XOM
==========================================================================
                            OLS Regression Results                            
==============================================================================
Dep. Variable:          premio_activo   R-squared:                       0.201
Model:                            OLS   Adj. R-squared:                  0.195
Method:                 Least Squares   F-statistic:                     34.13
Date:                Thu, 03 Sep 2026   Prob (F-statistic):           3.62e-08
Time:                        21:09:43   Log-Likelihood:                 174.33
No. Observations:                 138   AIC:                            -344.7
Df Residuals:                     136   BIC:                            -338.8
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
==================================================================================
                     coef    std err          t      P>|t|      [0.025      0.975]
----------------------------------------------------------------------------------
Intercept         -0.0030      0.006     -0.498      0.619      -0.015       0.009
premio_mercado     0.7644      0.131      5.842      0.000       0.506       1.023
==============================================================================
Omnibus:                       13.193   Durbin-Watson:                   1.653
Prob(Omnibus):                  0.001   Jarque-Bera (JB):               21.412
Skew:                           0.464   Prob(JB):                     2.24e-05
Kurtosis:                       4.692   Cond. No.                         22.3
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
# Resumen compacto de los dos, con todo lo que hay que interpretar
def resumen_capm(m, nombre):
    ci = m.conf_int()
    return pd.Series({
        "b0 (alfa mensual)":     m.params.iloc[0],
        "b0 anualizado":         m.params.iloc[0]*12,
        "SE(b0)":                m.bse.iloc[0],
        "t de b0":               m.tvalues.iloc[0],
        "p-value de b0":         m.pvalues.iloc[0],
        "b1 (beta)":             m.params.iloc[1],
        "SE(b1)":                m.bse.iloc[1],
        "t de b1":               m.tvalues.iloc[1],
        "p-value de b1":         m.pvalues.iloc[1],
        "IC 95% beta (inf)":     ci.iloc[1, 0],
        "IC 95% beta (sup)":     ci.iloc[1, 1],
        "R2":                    m.rsquared,
        "SD del residuo (mens.)": np.sqrt(m.mse_resid)}, name=nombre)

pd.DataFrame([resumen_capm(capm_jpm, "JPM"), resumen_capm(capm_xom, "XOM")]).T.round(4)
                           JPM     XOM
b0 (alfa mensual)       0.0014 -0.0030
b0 anualizado           0.0172 -0.0359
SE(b0)                  0.0042  0.0060
t de b0                 0.3456 -0.4980
p-value de b0           0.7302  0.6193
b1 (beta)               1.1049  0.7644
SE(b1)                  0.0904  0.1308
t de b1                12.2252  5.8423
p-value de b1           0.0000  0.0000
IC 95% beta (inf)       0.9262  0.5057
IC 95% beta (sup)       1.2836  1.0232
R2                      0.5236  0.2006
SD del residuo (mens.)  0.0476  0.0689

Interpretación completa, en frases que puedas escribir en un examen.

6.1.1 JPM

  • b_1 (beta) ≈ 1.0–1.2. Cuando el premio del mercado sube 1 punto porcentual en un mes, el premio de JP Morgan sube, en promedio, alrededor de 1.1 puntos porcentuales. Es decir, JPM tiene aproximadamente el mismo riesgo sistemático que el mercado, tal vez un poco más. Económicamente tiene sentido: un banco grande es un negocio cíclico y muy expuesto al ciclo económico, ni defensivo ni especulativo.

  • SE(b_1) ≈ 0.09. Es la desviación estándar del estimador de beta. Interpretación práctica: si repitiéramos el experimento con otra muestra de 139 meses del mismo proceso, el beta estimado se movería típicamente ±0.09 alrededor del verdadero.

  • t de b_1 ≈ 11. Es b_1/SE(b_1): el beta estimado está a 11 errores estándar de cero. Con p<0.001 rechazamos que el verdadero beta sea cero. Traducción: JPM sí está expuesto al mercado, lo cual, honestamente, no sorprende a nadie (ver inciso b).

  • IC 95% del beta ≈ [0.86, 1.22]. Aquí está el mensaje que importa: aun con 139 meses de datos, el intervalo tiene casi 0.4 de ancho. No sabes si el beta de JPM es 0.9 o 1.2. Si lo usas para cubrir una posición, tu cobertura trae ese error dentro.

  • b_0 (alfa) ≈ 0.0027 mensual ≈ 3.2% anual, con p \approx 0.5. Y ésta es la respuesta más importante del inciso: el alfa es estadísticamente indistinguible de cero. La lectura correcta no es “JPM generó 3.2% de alfa”; es “JPM tuvo un rendimiento 3.2% anual por encima de lo que el CAPM predecía, pero el dato es tan ruidoso que no podemos descartar que ese exceso sea puro azar”. Un p-value de 0.5 significa literalmente que la mitad de las muestras generadas por un mundo sin alfa producirían un resultado al menos así de extremo.

  • R^2 ≈ 0.49. El mercado explica cerca de la mitad de la variación mensual de JPM. La otra mitad es riesgo idiosincrático: cambios regulatorios, resultados trimestrales, reservas crediticias, cosas de JP Morgan.

  • SD(\varepsilon) ≈ 0.048 mensual (≈16% anual). Es el riesgo no sistemático, el que se puede diversificar. Aparece de nuevo en el inciso (e) y es el denominador del Information Ratio.

6.1.2 XOM

  • Beta ≈ 0.88–1.0, un poco por debajo del de JPM, y con un error estándar mayor (≈0.11). Menos sensible al mercado y peor estimado.

  • R^2 ≈ 0.31, claramente menor que el de JPM. Esto es económicamente informativo: gran parte de lo que mueve a Exxon es el precio del petróleo, que es un factor de riesgo que el CAPM de un solo factor no tiene. Ese “riesgo no explicado” del 69% no es todo idiosincrático en sentido estricto; parte es sistemático respecto a un factor que omitimos. Esta observación es exactamente el argumento que lleva a los modelos multifactoriales de la Parte V.

  • Alfa también positivo y también no significativo.

TipLa regla de oro para leer cualquier regresión

Ve siempre en este orden: (1) signo del coeficiente — ¿tiene sentido económico? (2) magnitud — ¿es grande en términos prácticos? (3) p-value — ¿lo puedo distinguir de cero? Un coeficiente puede ser económicamente enorme y estadísticamente basura (los alfas de esta tabla), o estadísticamente impecable y económicamente irrelevante.

6.2 (b) Probar H_0:\beta_{JPM}=1

La idea. statsmodels te regala el test de \beta=0 porque es el que sirve en la mayoría de las aplicaciones de estadística. En finanzas, ése es el test aburrido. El test interesante es contra 1.

El estadístico se construye igual, sólo cambia el valor bajo la nula:

t=\frac{b_1-1}{SE(b_1)}\ \sim\ t_{n-2}\quad\text{bajo }H_0:\beta=1

b1  = capm_jpm.params.iloc[1]
se1 = capm_jpm.bse.iloc[1]
gl  = int(capm_jpm.df_resid)          # grados de libertad = n - 2

t_calc = (b1 - 1) / se1
p_valor = 2 * (1 - stats.t.cdf(abs(t_calc), df=gl))    # prueba de dos colas
t_critico = stats.t.ppf(0.975, df=gl)

print("beta estimado (b1)        : %.4f" % b1)
beta estimado (b1)        : 1.1049
print("error estándar SE(b1)     : %.4f" % se1)
error estándar SE(b1)     : 0.0904
print("grados de libertad        : %d"   % gl)
grados de libertad        : 136
print("-"*58)
----------------------------------------------------------
print("t = (b1 - 1)/SE(b1)       : %.4f" % t_calc)
t = (b1 - 1)/SE(b1)       : 1.1607
print("valor crítico al 5%%       : ±%.4f" % t_critico)
valor crítico al 5%       : ±1.9776
print("p-value (dos colas)       : %.4f" % p_valor)
p-value (dos colas)       : 0.2478
print("-"*58)
----------------------------------------------------------
print("Conclusión:", "NO se rechaza H0: beta = 1" if abs(t_calc) < t_critico
      else "SE RECHAZA H0: beta = 1")
Conclusión: NO se rechaza H0: beta = 1
# La misma prueba, hecha por statsmodels con f_test (F = t^2 cuando hay 1 restricción)
print(capm_jpm.f_test("premio_mercado = 1"))
<F test: F=1.3472075292729522, p=0.2478002331261434, df_denom=136, df_num=1>
print("\nComprobación: t^2 = %.4f" % t_calc**2)

Comprobación: t^2 = 1.3472

Interpretación.

El estadístico t es pequeño (bastante menor que 2 en valor absoluto) y el p-value es alto. No rechazamos H_0: los datos son perfectamente compatibles con que el verdadero beta de JPM sea exactamente 1. Otra forma de verlo, equivalente: el 1 está dentro del intervalo de confianza al 95% que calculamos en el inciso (a).

¿Por qué esta hipótesis es más interesante que \beta=0?

  1. Porque \beta=0 es una hipótesis que nadie cree. Preguntar si una acción grande de un banco estadounidense tiene alguna relación con el mercado accionario estadounidense es preguntar algo cuya respuesta ya conocemos. Rechazar una nula obviamente falsa no te enseña nada. Como decían los estadísticos: no hagas pruebas cuya respuesta ya sabes.

  2. Porque \beta=1 es la frontera económica que sí importa. Es el punto que separa defensivo (\beta<1: amortigua al mercado) de agresivo (\beta>1: amplifica al mercado). Es una pregunta sobre la naturaleza del activo, no sobre su existencia.

  3. Porque de eso dependen decisiones concretas. Si \beta=1, para cubrir 1 millón de dólares de JPM necesito vender en corto 1 millón de índice. Si \beta=1.2, necesito 1.2 millones. La diferencia son 200 mil dólares de exposición residual. Y el resultado de esta prueba dice algo incómodo: no puedes descartar ninguno de los dos, así que tu cobertura trae un error que no puedes eliminar leyendo más datos históricos.

  4. El principio general. En cualquier regresión, la hipótesis nula interesante es la que corresponde al valor teórico o de referencia del problema, no al cero por default del software.

6.3 (c) ¿Son estables los betas en el tiempo?

La idea. Todo lo que hemos hecho supone implícitamente que existe “el” beta, un número fijo que estimamos con ruido. Vamos a poner esa suposición a prueba de la forma más simple: partimos la muestra a la mitad y estimamos el beta en cada mitad.

n_meses = len(r)
corte   = n_meses // 2
fecha_corte = r.index[corte]
print("Primera mitad : %s a %s (%d meses)"
      % (r.index[0].date(), r.index[corte-1].date(), corte))
Primera mitad : 2015-01-31 a 2020-09-30 (69 meses)
print("Segunda mitad : %s a %s (%d meses)"
      % (fecha_corte.date(), r.index[-1].date(), n_meses - corte))
Segunda mitad : 2020-10-31 a 2026-06-30 (69 meses)
filas = {}
for t in TICKERS:
    d = pd.DataFrame({"y": premR[t + "_prem"].values, "x": mkt_prem.values},
                     index=mkt_prem.index)
    m1 = smf.ols("y ~ x", data=d.iloc[:corte]).fit()      # primera mitad
    m2 = smf.ols("y ~ x", data=d.iloc[corte:]).fit()      # segunda mitad
    mt = smf.ols("y ~ x", data=d).fit()                   # muestra completa

    b_1, b_2 = m1.params.iloc[1], m2.params.iloc[1]
    se_1, se_2 = m1.bse.iloc[1], m2.bse.iloc[1]

    # Prueba informal de igualdad de betas entre submuestras:
    # las dos estimaciones son independientes, así que la varianza de la
    # diferencia es la suma de las varianzas.
    t_dif = (b_2 - b_1) / np.sqrt(se_1**2 + se_2**2)

    filas[t] = pd.Series({"beta 1a mitad": b_1, "beta 2a mitad": b_2,
                          "beta completo": mt.params.iloc[1],
                          "cambio": b_2 - b_1,
                          "cambio %": 100*(b_2 - b_1)/abs(b_1),
                          "t de la diferencia": t_dif})

estabilidad = pd.DataFrame(filas).T
estabilidad.round(4)
      beta 1a mitad  beta 2a mitad  ...   cambio %  t de la diferencia
AAPL         1.2103         1.0361  ...   -14.3923             -0.7763
GLD          0.0075         0.0835  ... 1,018.1079              0.4491
HYG          0.3772         0.3685  ...    -2.3027             -0.1791
JPM          1.1902         1.0242  ...   -13.9458             -0.9185
SPY          0.9537         0.9617  ...     0.8415              0.4440
TSLA         1.4670         1.9623  ...    33.7618              0.9094
WMT          0.2958         0.5526  ...    86.8081              1.3157
XOM          1.2299         0.3286  ...   -73.2833             -3.7200

[8 rows x 6 columns]
# Una visión más fina: beta rodante de 36 meses
def beta_rodante(ticker, ventana=36):
    """Beta estimado con una ventana móvil de 'ventana' meses."""
    y = premR[ticker + "_prem"]
    x = mkt_prem
    out = pd.Series(index=y.index, dtype=float)
    for i in range(ventana, len(y)+1):
        yy, xx = y.iloc[i-ventana:i], x.iloc[i-ventana:i]
        # beta = COV(y,x)/VAR(x): la definición, sin necesidad de OLS
        out.iloc[i-1] = np.cov(yy, xx, ddof=1)[0,1] / np.var(xx, ddof=1)
    return out.dropna()

plt.figure(figsize=(12, 6))
<Figure size 1200x600 with 0 Axes>
for t in ["TSLA", "AAPL", "JPM", "WMT", "GLD"]:
    plt.plot(beta_rodante(t), label=t, lw=1.8)
[<matplotlib.lines.Line2D object at 0x000002E05D5E4AD0>]
[<matplotlib.lines.Line2D object at 0x000002E05D5E4C20>]
[<matplotlib.lines.Line2D object at 0x000002E05D5E4D70>]
[<matplotlib.lines.Line2D object at 0x000002E05D5E4EC0>]
[<matplotlib.lines.Line2D object at 0x000002E05D5E5010>]
plt.axhline(1, color="black", ls="--", lw=1, label="beta = 1")
<matplotlib.lines.Line2D object at 0x000002E05D5E4980>
plt.axhline(0, color="grey", lw=0.8)
<matplotlib.lines.Line2D object at 0x000002E05D5E5160>
plt.title("Beta rodante de 36 meses: los betas se mueven, y mucho")
Text(0.5, 1.0, 'Beta rodante de 36 meses: los betas se mueven, y mucho')
plt.xlabel("Fecha"); plt.ylabel("Beta estimado")
Text(0.5, 0, 'Fecha')
Text(0, 0.5, 'Beta estimado')
plt.legend(ncol=3); plt.grid(alpha=0.3)
<matplotlib.legend.Legend object at 0x000002E05D5E52B0>
plt.show()

Interpretación.

Los betas no son estables. La tabla y sobre todo la gráfica lo muestran sin ambigüedad: hay instrumentos cuyo beta cambia 30%, 50% o más entre una mitad y otra de la muestra, y el beta rodante se pasea por rangos amplios. Casos típicos que verás:

  • SPY es la excepción: su beta se queda pegado a 1 en las dos mitades. Perfecto, porque es el mercado. Si tu SPY no sale estable en ~1, tienes un error de datos.
  • GLD puede pasar de un beta ligeramente negativo a uno ligeramente positivo. En el oro esto no es ruido puro: refleja que a veces el oro se comporta como refugio (beta negativo) y a veces como activo de riesgo más (beta positivo), dependiendo del régimen macro.
  • TSLA y WMT muestran cambios grandes. En TSLA parte es real (la empresa cambió de naturaleza), y parte es que su beta está tan mal estimado que los errores estándar son enormes.

Cuidado con la sobre-interpretación: mira la columna “t de la diferencia”. En la mayoría de los casos el cambio no es estadísticamente significativo. Es decir, no puedes concluir con seguridad “el beta cambió”; lo que sí puedes concluir, y es igual de grave, es que no puedes concluir que se quedó igual. Cualquiera de las dos posibilidades es mala noticia para quien cubre.

Qué implica para alguien que cubre con un beta histórico — y aquí está el punto que se conecta con todo el módulo:

  1. Tu cobertura está mal calibrada desde el día que la pones. Si cubres con el beta de los últimos 5 años y el beta verdadero de hoy es distinto, tu cartera “market-neutral” tiene beta residual. Cuando el mercado se mueve fuerte, esa exposición residual aparece justo cuando más duele.

  2. El beta suele moverse en el peor momento. Hay evidencia amplia de que las betas y las correlaciones suben hacia 1 en las crisis. Es decir, la cobertura falla precisamente cuando la necesitas: la diversificación se evapora en los momentos de estrés.

  3. Por eso en la práctica se hacen tres cosas: (i) reestimar el beta con ventanas móviles o con EWMA y rebalancear la cobertura seguido; (ii) usar shrinkage hacia 1 (el famoso beta ajustado de Blume: \beta_{aj}=\frac{2}{3}\beta+\frac{1}{3}, que existe porque los betas revierten a la media); (iii) modelar el beta como dependiente del régimen.

  4. Y la lección de humildad: si el beta —el parámetro mejor estimado de todo el modelo, con t de 10 o 20— es esto de inestable, imagina el alfa, cuyo t típico es 0.5.

6.4 (d) Una cartera con beta objetivo exactamente 0.5 usando TSLA y el activo libre de riesgo

La idea. El beta de una cartera es el promedio ponderado de los betas. El activo libre de riesgo tiene, por definición, \beta=0. Por lo tanto, si w es el peso en TSLA:

\beta_P=w\cdot\beta_{TSLA}+(1-w)\cdot 0=w\cdot\beta_{TSLA} \quad\Longrightarrow\quad w=\frac{\beta_P^{objetivo}}{\beta_{TSLA}}

beta_objetivo = 0.50
beta_tsla = betas["TSLA"]

w_tsla = beta_objetivo / beta_tsla
w_rf   = 1 - w_tsla

print("Beta de TSLA                          : %.4f" % beta_tsla)
Beta de TSLA                          : 1.7218
print("Peso necesario en TSLA (w)            : %.4f  (%.2f%%)" % (w_tsla, w_tsla*100))
Peso necesario en TSLA (w)            : 0.2904  (29.04%)
print("Peso en el activo libre de riesgo     : %.4f  (%.2f%%)" % (w_rf, w_rf*100))
Peso en el activo libre de riesgo     : 0.7096  (70.96%)
# --- Verificación por regresión ----------------------------------------------
# Construimos la serie de rendimientos de la cartera y le corremos el CAPM.
cartera_b05 = w_tsla*r["TSLA"] + w_rf*rf_m

d_ver = pd.DataFrame({"y": (cartera_b05 - rf_m).values, "x": mkt_prem.values},
                     index=mkt_prem.index)
m_ver = smf.ols("y ~ x", data=d_ver).fit()
ci_ver = m_ver.conf_int()

print("Beta estimado por regresión : %.4f" % m_ver.params.iloc[1])
Beta estimado por regresión : 0.5000
print("Error estándar              : %.4f" % m_ver.bse.iloc[1])
Error estándar              : 0.0788
print("IC 95%%                      : [%.4f, %.4f]" % (ci_ver.iloc[1,0], ci_ver.iloc[1,1]))
IC 95%                      : [0.3441, 0.6559]
print("R2                          : %.4f" % m_ver.rsquared)
R2                          : 0.2283
print("-"*60)
------------------------------------------------------------
print("Vol. anual de esta cartera  : %.4f" % (cartera_b05.std()*np.sqrt(12)))
Vol. anual de esta cartera  : 0.1628
print("Vol. anual de SPY           : %.4f" % (r["SPY"].std()*np.sqrt(12)))
Vol. anual de SPY           : 0.1504
print("Vol. anual de TSLA sola     : %.4f" % (r["TSLA"].std()*np.sqrt(12)))
Vol. anual de TSLA sola     : 0.5613

Interpretación — y una advertencia importante.

La verificación sale perfecta: el beta estimado es exactamente 0.50. No es coincidencia ni suerte: es álgebra. Construimos la serie multiplicando TSLA por una constante y sumándole una serie casi constante, y el beta es lineal, así que el resultado tenía que salir exacto.

Pero mira ahora el resto de la tabla, porque el beta objetivo se cumplió y aun así la cartera no es lo que parece:

  • El R^2 es bajísimo (~0.25). Un beta de 0.5 sugiere “medio mercado”, pero esta cartera no se parece en nada a medio mercado: apenas una cuarta parte de sus movimientos vienen del mercado.

  • La volatilidad de esta cartera es parecida o mayor a la de SPY completo, ¡a pesar de tener la mitad de su beta! Eso pasa porque el 26% que invertimos en TSLA trae consigo una enorme cantidad de riesgo idiosincrático, que el beta simplemente no mide.

WarningBeta no es riesgo. Beta es un tipo de riesgo.

Este inciso es la demostración más limpia de esa frase. Puedes fabricar cualquier beta que se te antoje —incluido cero— y no haber reducido tu riesgo total en absoluto. Un hedge fund puede reportar honestamente “nuestro beta al mercado es 0.1” y estar corriendo una cartera extremadamente riesgosa.

Por eso el reporte de riesgo de un fondo tiene que incluir, además del beta: volatilidad total, R^2, VaR, Expected Shortfall y máximo drawdown. Cada uno mide una cosa distinta, y omitir alguno es la forma más fácil (y perfectamente legal) de contar una historia incompleta.

6.5 (e) Si sólo se paga el riesgo sistemático, ¿por qué alguien tendría una sola acción?

La idea. Ésta es una pregunta conceptual, no de código. Pero vale la pena cuantificar primero cuánto te está costando no diversificar.

# ¿Cuánto riesgo NO PAGADO carga quien tiene una sola acción?
var_mercado = mkt_prem.var(ddof=1)

descomp = pd.DataFrame({
    "Varianza total":        r[capm_all.index].var(ddof=1),
    "Sistemática (β²σ²M)":   (capm_all["beta"]**2)*var_mercado,
    "No sistemática (σ²ε)":  capm_all["resid SD (monthly)"]**2})
descomp["% NO pagado"] = 100*descomp["No sistemática (σ²ε)"]/descomp["Varianza total"]
descomp["Vol. total anual"] = np.sqrt(descomp["Varianza total"]*12)
descomp["Vol. 'inútil' anual"] = np.sqrt(descomp["No sistemática (σ²ε)"]*12)
descomp.sort_values("% NO pagado", ascending=False).round(4)
      Varianza total  ...  Vol. 'inútil' anual
GLD           0.0020  ...               0.1539
WMT           0.0030  ...               0.1779
XOM           0.0059  ...               0.2387
TSLA          0.0263  ...               0.4953
AAPL          0.0059  ...               0.2035
JPM           0.0047  ...               0.1649
HYG           0.0004  ...               0.0439
SPY           0.0019  ...               0.0165

[8 rows x 6 columns]

Interpretación: cinco razones, ordenadas de la más “el CAPM tiene razón” a la más “el CAPM se queda corto”.

1. Porque creen (o su asesor les vendió) que pueden escoger ganadoras. Quien compra una sola acción no está aceptando el CAPM: lo está rechazando. Está apostando a que esa acción tiene un alfa positivo que él identificó y el mercado no. El CAPM dice que ese alfa es cero en promedio; el inversionista dice que él es la excepción. La industria entera de gestión activa —hedge funds incluidos— vive de exactamente esta creencia. Y la tabla de p-values del Workshop es la respuesta empírica: casi nunca se puede demostrar.

2. Porque no lo saben, y ésa es la respuesta más honesta. El inversionista promedio no distingue entre riesgo sistemático y no sistemático. Mira la columna “% NO pagado”: para varios instrumentos, más del 60% o 70% de su varianza es riesgo por el que el mercado no paga un centavo. Están cargando esa volatilidad gratis, sin saberlo. Diversificar es literalmente lo único gratis que existe, y mucha gente no lo hace.

3. Porque hay fricciones y restricciones reales.

  • Costos de transacción y tamaño. Comprar 500 acciones era caro antes de los ETFs. Hoy ya casi no es una excusa —justamente por eso los ETFs indizados existen y son enormes.
  • Impuestos. Vender una posición muy apreciada para diversificar genera un impuesto inmediato. Muchas carteras concentradas son prisioneras de su propia ganancia acumulada.
  • Capital humano y compensación. El empleado que recibe acciones de su empresa está doblemente expuesto: su salario y su patrimonio dependen del mismo empleador. Es el caso más caro de no diversificación, y suele venir empaquetado con restricciones de venta (vesting, lock-ups).
  • Control. Un fundador no tiene el 30% de su empresa por razones de media-varianza; lo tiene para controlarla.

4. Porque el CAPM es un modelo, y los modelos tienen supuestos que fallan. El resultado “sólo se paga el riesgo sistemático” depende de que todos puedan diversificar sin costo, que todos tengan la misma información y que sólo importe la varianza. Si relajas cualquiera de esos supuestos —información asimétrica, activos no negociables, inversionistas que se preocupan por la asimetría o por las colas— el resultado cambia. Merton (1987) construyó un modelo donde, si los inversionistas sólo conocen un subconjunto de acciones, el riesgo idiosincrático sí se paga.

5. Y la razón que conecta con este módulo. El CAPM dice que el riesgo idiosincrático no se paga si lo tienes sin cubrirlo. Pero todo el negocio de un hedge fund long/short es justamente lo contrario: tomar el riesgo idiosincrático a propósito, y quitarle el sistemático con un corto. Su tesis es: “yo sé algo sobre esta empresa; no quiero que mi apuesta dependa de si el mercado sube o baja”. El resultado —lo vimos en la cartera market-neutral del Workshop— es una cartera con \beta\approx0 cuyo rendimiento es puro riesgo idiosincrático concentrado.

Dicho de forma provocadora: un hedge fund market-neutral es una apuesta deliberada a que el CAPM está equivocado. Si el CAPM tuviera razón al 100%, ese fondo tendría un rendimiento esperado exactamente igual a la tasa libre de riesgo, y cobraría 2-and-20 por ello.

7 CHALLENGE 5 — Solución

7.1 (a) Medidas de desempeño de la cartera 70% SPY / 30% GLD

La idea. Le vamos a agregar oro a una cartera de mercado y a preguntarnos si ayudó. La trampa del ejercicio es que la respuesta depende de qué medida uses, y la parte que se califica es explicar por qué difieren.

Un detalle técnico importante antes del código:

NoteCómo se construye correctamente la serie de una cartera

Los pesos se aplican a rendimientos simples, no a rendimientos cc (ésa es la regla del Workshop). Entonces el procedimiento es:

  1. Calcular el rendimiento simple de la cartera: R_{P,t}=0.7R_{SPY,t}+0.3R_{GLD,t}
  2. Convertirlo a cc para poder anualizar y correr regresiones: r_{P,t}=\ln(1+R_{P,t})

Aplicar los pesos directamente a los r es la trampa contra la que advierte el Workshop.

# Paso 1: cartera en rendimientos SIMPLES (rebalanceo mensual implícito)
cartera_7030_simple = 0.70*Rs["SPY"] + 0.30*Rs["GLD"]

# Paso 2: la pasamos a cc para anualizar y para las regresiones
cartera_7030 = np.log(1 + cartera_7030_simple)

tabla_c5a = pd.DataFrame([
    performance(r["SPY"],      "100% SPY"),
    performance(cartera_7030,  "70% SPY / 30% GLD"),
    performance(r["GLD"],      "100% GLD")])

tabla_c5a.T.round(4)
                   100% SPY  70% SPY / 30% GLD  100% GLD
Rend. anual          0.1375             0.1335    0.1077
Vol. anual           0.1504             0.1180    0.1543
Beta                 0.9580             0.6838    0.0478
Sharpe               0.7263             0.8995    0.5383
Treynor              0.1139             0.1545    1.7285
Alfa Jensen (an)    -0.0068             0.0230    0.0768
p-value del alfa     0.1785             0.1277    0.1008
Information ratio       NaN            -0.0555   -0.1278
Tracking error       0.0000             0.0624    0.2073
M2                   0.0000             0.0260   -0.0282
print("Correlación mensual SPY–GLD: %.4f" % r[["SPY","GLD"]].corr().iloc[0,1])
Correlación mensual SPY–GLD: 0.0750
print()
# ¿Cuánto riesgo se ahorró y cuánto rendimiento se sacrificó?
vol_sin_div = 0.70*vol_a["SPY"] + 0.30*vol_a["GLD"]
print("Vol. anual si NO hubiera diversificación : %.4f" % vol_sin_div)
Vol. anual si NO hubiera diversificación : 0.1516
print("Vol. anual real de la cartera 70/30      : %.4f" % (cartera_7030.std()*np.sqrt(12)))
Vol. anual real de la cartera 70/30      : 0.1180
print("Vol. anual de SPY sola                   : %.4f" % vol_a["SPY"])
Vol. anual de SPY sola                   : 0.1504
# Visualización: dónde cae cada opción en el plano riesgo-rendimiento
plt.figure(figsize=(10, 6))
<Figure size 1000x600 with 0 Axes>
for nombre in tabla_c5a.index:
    x = tabla_c5a.loc[nombre, "Vol. anual"]
    y = tabla_c5a.loc[nombre, "Rend. anual"]
    plt.scatter(x, y, s=140, zorder=5)
    plt.annotate(nombre, (x, y), textcoords="offset points", xytext=(10, 5))
<matplotlib.collections.PathCollection object at 0x000002E05D673B10>
Text(10, 5, '100% SPY')
<matplotlib.collections.PathCollection object at 0x000002E05D673D90>
Text(10, 5, '70% SPY / 30% GLD')
<matplotlib.collections.PathCollection object at 0x000002E05D7CC050>
Text(10, 5, '100% GLD')
# La recta que pasa por el activo libre de riesgo y por SPY:
# todo lo que quede ARRIBA de ella le ganó al mercado ajustando por riesgo.
xg = np.linspace(0, 0.25, 50)
sharpe_spy = tabla_c5a.loc["100% SPY", "Sharpe"]
plt.plot(xg, RF + sharpe_spy*xg, "r--", lw=2,
         label=f"Recta del mercado (Sharpe = {sharpe_spy:.3f})")
[<matplotlib.lines.Line2D object at 0x000002E05D6670E0>]
plt.scatter(0, RF, color="black", s=80, zorder=5)
<matplotlib.collections.PathCollection object at 0x000002E05D7CC2D0>
plt.annotate("Activo libre de riesgo", (0, RF), textcoords="offset points", xytext=(10, -12))
Text(10, -12, 'Activo libre de riesgo')
plt.xlabel("Volatilidad anual"); plt.ylabel("Rendimiento anual")
Text(0.5, 0, 'Volatilidad anual')
Text(0, 0.5, 'Rendimiento anual')
plt.title("¿Agregar oro movió la cartera por encima de la recta del mercado?")
Text(0.5, 1.0, '¿Agregar oro movió la cartera por encima de la recta del mercado?')
plt.legend(); plt.grid(alpha=0.3)
<matplotlib.legend.Legend object at 0x000002E05D666E40>
plt.show()

Interpretación — y aquí sí hay que ser cuidadoso, porque las medidas se contradicen.

Lo que muestran los números (patrón típico):

Medida Qué pasó al agregar oro Lectura
Rendimiento anual Baja El oro rindió menos que el S&P; el promedio ponderado baja.
Volatilidad anual Baja, y baja más de lo proporcional Diversificación pura: la correlación SPY–GLD es ≈0.
Sharpe Sube Bajó más el riesgo que el rendimiento. Ayudó.
Beta Baja (de ≈1 a ≈0.7) El oro no tiene exposición al mercado.
Treynor Sube El beta bajó más que el premio.
Alfa de Jensen Sube, pero con p-value alto No se puede distinguir de cero.
Information Ratio Negativo Contra SPY como benchmark, la cartera perdió.
Positivo (≈ +1 punto porcentual) Ajustada al riesgo del mercado, la cartera habría rendido ~1 pp más.

¿Ayudó o no ayudó el oro? Las tres respuestas correctas, y las tres distintas:

  1. Sí, si esta cartera es todo tu patrimonio. El Sharpe sube y el es positivo. M² es la medida que traduce el resultado a algo que un cliente entiende: “si hubiéramos apalancado la cartera 70/30 hasta tener exactamente la misma volatilidad que el mercado, habría rendido ~1 punto porcentual más al año que el mercado”. Ésa es una mejora real, aunque discreta.

  2. No, si te van a evaluar contra el S&P 500. El Information Ratio es negativo. IR compara contra un benchmark, no contra el activo libre de riesgo: mide rendimiento activo por unidad de riesgo activo. La cartera quedó por debajo del SPY en rendimiento, y encima generó un tracking error de varios puntos. Un gestor con mandato de “superar al S&P” sería despedido por esta cartera aunque su Sharpe sea mejor.

  3. Depende de qué más tengas. El Treynor sube fuerte, pero eso es engañoso: sube porque el denominador (beta) se colapsó, no porque el numerador haya mejorado. Ojo con esa trampa —volveremos a ella en el inciso (d).

Por qué se contradicen: cada medida tiene un denominador distinto.

\text{Sharpe}=\frac{E[R_P]-R_f}{\sigma_P} \qquad \text{Treynor}=\frac{E[R_P]-R_f}{\beta_P} \qquad IR=\frac{E[R_P-R_B]}{\sigma(R_P-R_B)}

  • El Sharpe te pregunta: ¿esto es un buen negocio por sí solo? Su referencia es el activo libre de riesgo y su riesgo es el total.
  • El Treynor te pregunta: ¿esto es un buen ingrediente para una cartera ya diversificada? Su riesgo es sólo el sistemático.
  • El IR te pregunta: ¿le ganaste a tu benchmark? Su referencia no es el activo libre de riesgo, es el índice, y su riesgo es la desviación respecto al índice.

Son tres preguntas distintas. Tienen tres respuestas distintas. Ninguna está mal.

ImportantLa pregunta que hay que hacerle a cualquier fondo

Cuando un hedge fund pone un ratio en su presentación, la primera pregunta no es “¿es bueno ese número?” sino “¿por qué escogiste justamente esa medida?”. Este inciso muestra que con la misma cartera y los mismos datos se pueden contar tres historias distintas y las tres son técnicamente ciertas.

7.2 (b) El fondo de 12% con 6% de volatilidad: Sharpe y M²

La idea. Un ejercicio numérico puro, con datos dados en el enunciado. La pregunta difícil es la última: explicar M² sin fórmulas.

# Datos del enunciado
R_fondo,   sd_fondo   = 0.12, 0.06
R_mercado, sd_mercado = 0.11, 0.16
Rf = 0.035

# --- Sharpe de cada uno -------------------------------------------------------
S_fondo   = (R_fondo   - Rf) / sd_fondo
S_mercado = (R_mercado - Rf) / sd_mercado

# --- M2: (diferencia de Sharpes) x (volatilidad del mercado) ------------------
M2 = (S_fondo - S_mercado) * sd_mercado

print("Sharpe del fondo    : (%.4f - %.4f)/%.4f = %.4f" % (R_fondo, Rf, sd_fondo, S_fondo))
Sharpe del fondo    : (0.1200 - 0.0350)/0.0600 = 1.4167
print("Sharpe del mercado  : (%.4f - %.4f)/%.4f = %.4f" % (R_mercado, Rf, sd_mercado, S_mercado))
Sharpe del mercado  : (0.1100 - 0.0350)/0.1600 = 0.4688
print("-"*66)
------------------------------------------------------------------
print("M2 = (S_fondo - S_mercado) * SD_mercado = %.4f  (%.2f puntos porcentuales)"
      % (M2, M2*100))
M2 = (S_fondo - S_mercado) * SD_mercado = 0.1517  (15.17 puntos porcentuales)
# --- La MISMA respuesta, construida "a mano", que es como se entiende ---------
# Paso 1: ¿cuánto hay que apalancar el fondo para que su vol. sea la del mercado?
apalancamiento = sd_mercado / sd_fondo

# Paso 2: rendimiento de esa versión apalancada.
#   Pedimos prestado (y-1) a la tasa Rf e invertimos y en el fondo.
R_fondo_apalancado = Rf + apalancamiento*(R_fondo - Rf)

print("Apalancamiento necesario (y = SD_mkt / SD_fondo) : %.4f  (%.2fx)"
      % (apalancamiento, apalancamiento))
Apalancamiento necesario (y = SD_mkt / SD_fondo) : 2.6667  (2.67x)
print("Con eso, invierte %.1f%% en el fondo y pide prestado %.1f%%"
      % (apalancamiento*100, (apalancamiento-1)*100))
Con eso, invierte 266.7% en el fondo y pide prestado 166.7%
print("-"*66)
------------------------------------------------------------------
print("Rendimiento del fondo apalancado a vol. de 16%%   : %.4f  (%.2f%%)"
      % (R_fondo_apalancado, R_fondo_apalancado*100))
Rendimiento del fondo apalancado a vol. de 16%   : 0.2617  (26.17%)
print("Rendimiento del mercado                          : %.4f  (%.2f%%)"
      % (R_mercado, R_mercado*100))
Rendimiento del mercado                          : 0.1100  (11.00%)
print("DIFERENCIA (= M2)                                : %.4f  (%.2f pp)"
      % (R_fondo_apalancado - R_mercado, (R_fondo_apalancado - R_mercado)*100))
DIFERENCIA (= M2)                                : 0.1517  (15.17 pp)

Interpretación.

El Sharpe del fondo es aproximadamente 1.42 contra 0.47 del mercado: el fondo es tres veces más eficiente por unidad de riesgo. Pero el fondo rindió 12% y el mercado 11%: en rendimiento crudo, apenas le ganó por un punto. Ésta es exactamente la situación donde el rendimiento crudo engaña, porque el fondo lo consiguió con menos de la mitad de la volatilidad.

M² sale +15.2 puntos porcentuales, y la construcción “a mano” explica de dónde salen: apalancando el fondo 2.67 veces llegas a la misma volatilidad que el mercado (16%), y a ese nivel de riesgo el fondo habría rendido ~26% contra el 11% del mercado.

Explicación para el cliente, en un párrafo y sin fórmulas:

Comparar el 12% de nuestro fondo con el 11% del mercado no es una comparación justa, porque el mercado tomó mucho más riesgo que nosotros: sus resultados oscilaron casi tres veces más que los nuestros. Para comparar peras con peras, hagamos el siguiente ejercicio mental: subamos nuestro fondo al mismo nivel de riesgo que el mercado. Como nuestra estrategia es mucho más estable, para llegar a ese nivel de sobresaltos tendríamos que invertir alrededor de dos veces y media el capital, financiando la diferencia a la tasa de Cetes. Puestos así, hombro con hombro y con exactamente el mismo nivel de riesgo, nuestro fondo habría entregado alrededor de 26% al año contra el 11% del mercado: quince puntos porcentuales de ventaja. Ése es el valor real que estamos aportando; el 12% que usted ve en el estado de cuenta simplemente refleja que decidimos correr mucho menos riesgo del que corre el índice.

Fíjate en la virtud de M²: la respuesta viene en puntos porcentuales de rendimiento, que es la única unidad que todo el mundo entiende. Un Sharpe de 1.42 no le dice nada a un cliente; “quince puntos porcentuales de ventaja al mismo riesgo” sí.

WarningLas tres letras chiquitas de este cálculo
  1. Supone que puedes apalancarte a la tasa libre de riesgo. En la realidad, un fondo paga varios puntos por encima de Cetes. Ese costo se come parte del M².
  2. Supone que el riesgo del fondo es su volatilidad. Un fondo de 6% de volatilidad que vende seguros catastróficos tiene un Sharpe espectacular… hasta el año en que no lo tiene. La volatilidad no ve las colas.
  3. No dice nada sobre significancia estadística. Con pocos años de datos, un Sharpe de 1.4 puede ser suerte. Siempre pide la serie completa y el periodo.

7.3 (c) Mover el flujo de 90 millones un trimestre después

La idea. El TWR es un promedio geométrico de los rendimientos del fondo; el MWR es la TIR de los flujos de los inversionistas. Vamos a mover un flujo grande de lugar y ver cuál de los dos se mueve.

flujos = pd.read_csv("hf_fund_flows.csv")
flujos["QuarterReturn"] = flujos["EndingNAV"]/(flujos["BeginningNAV"] + flujos["NetFlow"]) - 1
flujos
  Quarter  QuarterEnd  BeginningNAV  NetFlow  EndingNAV  QuarterReturn
0  2025Q3  2025-09-30      100.0000   0.0000   110.0000         0.1000
1  2025Q4  2025-12-31      110.0000  90.0000   180.0000        -0.1000
2  2026Q1  2026-03-31      180.0000  20.0000   224.0000         0.1200
3  2026Q2  2026-06-30      224.0000 -24.0000   216.0000         0.0800
q = flujos["QuarterReturn"].values     # los cuatro rendimientos trimestrales

def calcula_twr(rend_trimestrales):
    """Time-weighted return: media geométrica de los rendimientos del fondo."""
    twr_q = np.prod(1 + rend_trimestrales)**(1/len(rend_trimestrales)) - 1
    return twr_q, (1 + twr_q)**4 - 1

def calcula_mwr(nav_inicial, flujos_netos, nav_final):
    """
    Money-weighted return: la TIR de los flujos de los INVERSIONISTAS.
    Convención de signos (desde el punto de vista del inversionista):
      dinero que ENTRA al fondo  -> flujo NEGATIVO (sale de su bolsillo)
      dinero que SALE del fondo  -> flujo POSITIVO
    """
    cf  = [-(nav_inicial + flujos_netos[0])]          # t=0: capital inicial + flujo del Q1
    cf += [-f for f in flujos_netos[1:]]              # t=1..3: flujos al inicio de cada Q
    cf += [nav_final]                                 # t=4: se devuelve el NAV final
    npv = lambda tasa: sum(c/(1 + tasa)**i for i, c in enumerate(cf))
    tir_q = optimize.brentq(npv, -0.9, 1.0)           # busca la raíz del VPN
    return cf, tir_q, (1 + tir_q)**4 - 1

# --- ESCENARIO ORIGINAL -------------------------------------------------------
cf0, mwr_q0, mwr_a0 = calcula_mwr(flujos["BeginningNAV"].iloc[0],
                                  list(flujos["NetFlow"]),
                                  flujos["EndingNAV"].iloc[-1])
twr_q0, twr_a0 = calcula_twr(q)

print("ESCENARIO ORIGINAL (los 90 M llegan justo ANTES del trimestre malo)")
ESCENARIO ORIGINAL (los 90 M llegan justo ANTES del trimestre malo)
print("Flujos del inversionista:", np.round(cf0, 2))
Flujos del inversionista: [-100.  -90.  -20.   24.  216.]
print("TWR: %.4f por trimestre  ->  %.4f anual" % (twr_q0, twr_a0))
TWR: 0.0461 por trimestre  ->  0.1975 anual
print("MWR: %.4f por trimestre  ->  %.4f anual" % (mwr_q0, mwr_a0))
MWR: 0.0415 por trimestre  ->  0.1764 anual
print("Brecha (TWR - MWR): %.4f anual  (%.2f pp)" % (twr_a0 - mwr_a0, (twr_a0-mwr_a0)*100))
Brecha (TWR - MWR): 0.0211 anual  (2.11 pp)
# --- ESCENARIO ALTERNATIVO: los 90 M llegan un trimestre DESPUÉS --------------
# Los rendimientos trimestrales del fondo NO cambian (son del gestor).
# Lo que cambia son los NAV, porque hay menos dinero durante el trimestre malo.
flujos_nuevos = [0.0, 0.0, 90.0 + 20.0, -24.0]      # los 90 se mueven de Q4-2025 a Q1-2026

nav = [flujos["BeginningNAV"].iloc[0]]
nav_final_trim = []
for i in range(4):
    base = nav[i] + flujos_nuevos[i]      # capital que efectivamente trabaja ese trimestre
    fin  = base * (1 + q[i])              # se le aplica el MISMO rendimiento del fondo
    nav_final_trim.append(fin)
    nav.append(fin)

cf1, mwr_q1, mwr_a1 = calcula_mwr(nav[0], flujos_nuevos, nav_final_trim[-1])
twr_q1, twr_a1 = calcula_twr(q)            # idéntico: los rendimientos no cambiaron

print("ESCENARIO ALTERNATIVO (los 90 M llegan DESPUÉS del trimestre malo)")
ESCENARIO ALTERNATIVO (los 90 M llegan DESPUÉS del trimestre malo)
print("NAV al cierre de cada trimestre:", np.round(nav_final_trim, 2))
NAV al cierre de cada trimestre: [110.    99.   234.08 226.89]
print("Flujos del inversionista:", np.round(cf1, 2))
Flujos del inversionista: [-100.     -0.   -110.     24.    226.89]
print("TWR: %.4f por trimestre  ->  %.4f anual" % (twr_q1, twr_a1))
TWR: 0.0461 por trimestre  ->  0.1975 anual
print("MWR: %.4f por trimestre  ->  %.4f anual" % (mwr_q1, mwr_a1))
MWR: 0.0636 por trimestre  ->  0.2797 anual
pd.DataFrame({
    "Escenario original (90M antes del mal trimestre)": [twr_a0, mwr_a0, twr_a0 - mwr_a0],
    "Escenario alternativo (90M después)":              [twr_a1, mwr_a1, twr_a1 - mwr_a1]},
    index=["TWR anual", "MWR anual", "Brecha TWR - MWR"]).round(4)
                  Escenario original (90M antes del mal trimestre)  Escenario alternativo (90M después)
TWR anual                                                   0.1975                               0.1975
MWR anual                                                   0.1764                               0.2797
Brecha TWR - MWR                                            0.0211                              -0.0822

Interpretación.

Cuál cambió: sólo el MWR. El TWR es exactamente idéntico en los dos escenarios (mismo número, hasta el último decimal). El MWR cambia mucho: pasa de estar por debajo del TWR a estar por encima de él.

Por qué.

  • El TWR se construye multiplicando (1+R_1)(1+R_2)(1+R_3)(1+R_4) y sacando la raíz cuarta. Los R_t son los rendimientos del fondo, y no cambiaron: el gestor tomó las mismas decisiones y obtuvo los mismos porcentajes. El TWR por construcción neutraliza el tamaño del capital: es lo mismo administrar 100 que 1,000 millones si el porcentaje es el mismo.

  • El MWR es una TIR, y una TIR pondera cada periodo por el dinero que había trabajando en ese periodo. En el escenario original, los 90 millones entraron justo antes del trimestre de −10%: la peor pérdida se aplicó al capital más grande. En el escenario alternativo esos 90 millones se perdieron la caída y llegaron a tiempo para el +12%. Los porcentajes son los mismos; los dólares no.

Qué mediría el gestor y qué mide el inversionista. Ésta es la respuesta que se califica:

  • El gestor quiere reportar el TWR, y en este caso tiene razón en quererlo: él no decide cuándo llega el dinero. Sería injusto castigarlo porque un inversionista eligió mal el momento de entrar. Por eso los GIPS (Global Investment Performance Standards) obligan a reportar TWR para fondos de mercado abierto: es la única forma de comparar gestores entre sí.

  • El inversionista vive el MWR. El TWR es lo que rindió el fondo; el MWR es lo que rindió su dinero. La diferencia entre los dos tiene nombre en la industria: la brecha de comportamiento (behavior gap), y en promedio es negativa, porque los inversionistas compran después de trimestres buenos y salen después de trimestres malos.

  • Y la parte incómoda: un gestor que controla los flujos —private equity, capital call funds, o un hedge fund que abre y cierra su capacidad a voluntad— debe ser evaluado con MWR/TIR, porque el timing del capital fue una decisión suya. Por eso el private equity reporta TIR y los hedge funds líquidos reportan TWR.

ImportantCómo se explota esta asimetría en la vida real

Un gestor que puede escoger qué número publicar escogerá el que le convenga. Los dos patrones que hay que reconocer:

  • “Rendimiento del fondo desde su inicio: 19.75% anual.” Si eso es TWR y el fondo creció mucho justo antes de un mal periodo, sus inversionistas ganaron bastante menos. Pide el MWR agregado.
  • “TIR desde inicio: 28%.” Si es un fondo que llama capital a voluntad, ese número puede estar inflado por haber llamado poco capital al principio (cuando los retornos son altos y la base chica). Pide el múltiplo sobre capital invertido (MOIC/TVPI).

La regla: siempre pregunta cuál de los dos, y pide el otro.

7.4 (d) Por qué Treynor es una mala medida para un hedge fund market-neutral

La idea. Ya lo vimos de reojo en el inciso (a); ahora lo hacemos explícito con la cartera market-neutral del Workshop.

comparacion_tn = pd.DataFrame([
    performance(r["SPY"],       "SPY (el mercado)"),
    performance(r[ASSETS] @ w_tan_lo, "Tangencia long-only"),
    performance(mkt_neutral,    "Market-neutral (AAPL/WMT vs SPY)")])
comparacion_tn[["Rend. anual", "Vol. anual", "Beta", "Sharpe", "Treynor",
                "Alfa Jensen (an)", "p-value del alfa", "Information ratio"]].round(4)
                                  Rend. anual  ...  Information ratio
SPY (el mercado)                       0.1375  ...                NaN
Tangencia long-only                    0.1650  ...             0.2688
Market-neutral (AAPL/WMT vs SPY)       0.0965  ...            -0.1861

[3 rows x 8 columns]
# La demostración de por qué explota: Treynor como función del beta
beta_mn = comparacion_tn.loc["Market-neutral (AAPL/WMT vs SPY)", "Beta"]
premio_mn = (mkt_neutral - rf_m).mean()*12

print("Beta de la cartera market-neutral : %.6f" % beta_mn)
Beta de la cartera market-neutral : 0.032465
print("Premio anual de la cartera        : %.6f" % premio_mn)
Premio anual de la cartera        : 0.072457
print("Treynor = premio / beta           : %.4f" % (premio_mn/beta_mn))
Treynor = premio / beta           : 2.2318
print()
print("Ahora fíjate qué frágil es. Si el beta se moviera un poquito:")
Ahora fíjate qué frágil es. Si el beta se moviera un poquito:
for b in [beta_mn*0.5, beta_mn, beta_mn*2, -beta_mn]:
    print("   beta = %+.6f  ->  Treynor = %+10.4f" % (b, premio_mn/b))
   beta = +0.016233  ->  Treynor =    +4.4637
   beta = +0.032465  ->  Treynor =    +2.2318
   beta = +0.064931  ->  Treynor =    +1.1159
   beta = -0.032465  ->  Treynor =    -2.2318

Interpretación — la respuesta en tres partes.

1. El problema matemático: división por (casi) cero.

T_P=\frac{E[R_P]-R_f}{\beta_P}

Una cartera market-neutral está diseñada para tener \beta_P \approx 0. Ése es su producto, no un accidente. Pero eso significa que el denominador del Treynor tiende a cero, y el cociente estalla hacia ±infinito. El mini-experimento del código lo muestra: mover el beta de 0.001 a 0.002 —un cambio que es puro ruido de estimación— parte el Treynor a la mitad. Una medida cuyo valor cambia 100% cuando el insumo cambia en una cantidad estadísticamente indistinguible de cero no es una medida: es un generador de números.

2. El problema de signo, que es todavía peor. El beta estimado de una cartera market-neutral bien construida es estadísticamente cero, lo cual significa que puede salir negativo por puro azar. Si sale negativo, el Treynor se vuelve negativo y enorme, y la cartera aparece hasta el fondo de la tabla —o, si el premio también es negativo, aparece hasta arriba—. El ranking deja de tener significado.

3. El problema conceptual, que es el que hay que entender. El Treynor no está roto por accidente: está midiendo la cosa equivocada. El Treynor supone que el único riesgo que importa es el sistemático, porque supone que el resto se diversifica dentro de una cartera más grande. Pero una estrategia market-neutral no tiene riesgo sistemático por construcción: todo su riesgo —y todo su rendimiento— es idiosincrático. Pedirle al Treynor que evalúe eso es pedirle a un termómetro que mida el peso.

Y esto se puede explotar deliberadamente: un gestor que quiera verse bien puede cubrir su beta hasta acercarlo a cero y luego publicar su Treynor. El número saldrá espectacular sin que su estrategia haya mejorado en absoluto. Es una de las manipulaciones más limpias que existen, porque no requiere mentir en ningún dato.

¿Qué usar en su lugar? Depende de la pregunta:

Pregunta Medida Por qué
¿Es buen negocio por sí solo? Sharpe Su denominador es el riesgo total, que es justo el que carga esta estrategia. Es la medida primaria de un hedge fund.
¿El gestor tiene habilidad, o sólo exposiciones? Alfa multifactorial + su p-value Corres un modelo de factores (Parte V) y ves qué queda. Con el p-value, no sin él.
¿Cuánta habilidad por unidad de riesgo activo? Information Ratio (\alpha/\sigma_\varepsilon) Normaliza el alfa por el riesgo que hubo que tomar para obtenerlo. Es el “Sharpe de la habilidad”.
¿Qué tan mal me puede ir? VaR, Expected Shortfall, máximo drawdown Porque estas estrategias suelen tener colas gordas y asimetría negativa que la volatilidad no ve.
¿Vale la pena agregarla a mi cartera? Correlación con lo que ya tengo + Sharpe El valor de un fondo market-neutral está justamente en su baja correlación, no en su rendimiento absoluto.

Mi recomendación para este caso concreto: el Information Ratio calculado con el alfa de un modelo multifactorial (FF5), acompañado siempre del p-value del alfa, y con el máximo drawdown al lado. El Sharpe como número de encabezado; el IR como número de diagnóstico.

8 CHALLENGE 6 — Solución

8.1 (a) VaR y ES al 95% y 99% de los 8 instrumentos

La idea. El VaR histórico es literalmente un percentil de la distribución de rendimientos, con el signo cambiado. El Expected Shortfall es el promedio de todo lo que quedó más allá de ese percentil. Uno te da la puerta; el otro te dice qué tan profundo es el hoyo detrás de la puerta.

def var_historico(rends, c=0.95):
    """VaR histórico: menos el percentil (1-c) de la distribución empírica."""
    return -np.percentile(rends, (1 - c)*100)

def expected_shortfall(rends, c=0.95):
    """ES: promedio de los rendimientos que quedaron POR DEBAJO del percentil (1-c)."""
    corte = np.percentile(rends, (1 - c)*100)
    return -rends[rends <= corte].mean()

tabla_c6a = pd.DataFrame({
    "Vol. mensual":  r.std(),
    "VaR 95%":       r.apply(lambda c: var_historico(c, 0.95)),
    "ES 95%":        r.apply(lambda c: expected_shortfall(c, 0.95)),
    "VaR 99%":       r.apply(lambda c: var_historico(c, 0.99)),
    "ES 99%":        r.apply(lambda c: expected_shortfall(c, 0.99)),
    "Peor mes":      r.min(),
    "Asimetría":     r.apply(stats.skew),
    "Curtosis exc.": r.apply(stats.kurtosis)})

# Rankings: 1 = el MÁS riesgoso
tabla_c6a["Rank por ES 95%"] = tabla_c6a["ES 95%"].rank(ascending=False)
tabla_c6a["Rank por vol."]   = tabla_c6a["Vol. mensual"].rank(ascending=False)
tabla_c6a["Diferencia de rank"] = tabla_c6a["Rank por vol."] - tabla_c6a["Rank por ES 95%"]

tabla_c6a.sort_values("ES 95%", ascending=False).round(4)
        Vol. mensual  VaR 95%  ...  Rank por vol.  Diferencia de rank
Ticker                         ...                                   
TSLA          0.1620   0.2282  ...         1.0000              0.0000
XOM           0.0767   0.1166  ...         3.0000              1.0000
JPM           0.0688   0.0957  ...         4.0000              1.0000
AAPL          0.0771   0.1175  ...         2.0000             -2.0000
WMT           0.0549   0.0700  ...         5.0000              0.0000
SPY           0.0434   0.0668  ...         7.0000              1.0000
GLD           0.0446   0.0635  ...         6.0000             -1.0000
HYG           0.0211   0.0255  ...         8.0000              0.0000

[8 rows x 11 columns]
# ¿Cuántas observaciones sostienen cada número? Esto es CRUCIAL para el 99%.
n = len(r)
print("Número de meses en la muestra: %d" % n)
Número de meses en la muestra: 138
print("Observaciones en la cola al 95%%: %.1f  (el 5%% de %d)" % (n*0.05, n))
Observaciones en la cola al 95%: 6.9  (el 5% de 138)
print("Observaciones en la cola al 99%%: %.1f  (el 1%% de %d)  <-- ¡ojo!" % (n*0.01, n))
Observaciones en la cola al 99%: 1.4  (el 1% de 138)  <-- ¡ojo!
# Visualización del ranking: volatilidad vs Expected Shortfall
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
t_vol = tabla_c6a.sort_values("Vol. mensual", ascending=False)
t_es  = tabla_c6a.sort_values("ES 95%", ascending=False)
axes[0].barh(t_vol.index[::-1], t_vol["Vol. mensual"][::-1], color="steelblue")
<BarContainer object of 8 artists>
axes[0].set_title("Ranking por VOLATILIDAD mensual"); axes[0].grid(alpha=0.3, axis="x")
Text(0.5, 1.0, 'Ranking por VOLATILIDAD mensual')
axes[1].barh(t_es.index[::-1], t_es["ES 95%"][::-1], color="indianred")
<BarContainer object of 8 artists>
axes[1].set_title("Ranking por EXPECTED SHORTFALL 95%"); axes[1].grid(alpha=0.3, axis="x")
Text(0.5, 1.0, 'Ranking por EXPECTED SHORTFALL 95%')
plt.tight_layout(); plt.show()

Interpretación.

¿Son iguales los dos rankings? Casi, pero no del todo — y las excepciones son justo lo interesante.

TSLA encabeza ambos rankings y HYG cierra ambos: para los extremos, volatilidad y ES cuentan la misma historia. Eso no sorprende, porque para una distribución normal el ES es simplemente la volatilidad multiplicada por una constante:

ES_c^{normal}=\mu+\sigma\cdot\frac{\phi(z_{1-c})}{1-c}

Si todos los activos fueran normales, los dos rankings serían idénticos por construcción. Toda diferencia entre ellos es, por lo tanto, una medida directa de qué tan poco normales son los datos.

Dónde difieren más, y por qué. Busca en la tabla las filas donde “Diferencia de rank” no es cero, y ve las columnas de asimetría y curtosis en esas filas. El patrón que vas a encontrar:

  • Los instrumentos que suben de posición al pasar de volatilidad a ES son los que tienen asimetría más negativa y/o curtosis más alta — típicamente XOM y SPY. Su volatilidad “promedia” meses buenos con meses malos y los hace ver moderados; su cola izquierda es peor de lo que su volatilidad sugiere. XOM es el caso de libro: volatilidad media, pero con episodios de caídas brutales (2015-2016, marzo 2020) que la volatilidad diluye y el ES no.

  • Los que bajan de posición son los que tienen asimetría menos negativa o positiva: parte de su volatilidad está en la cola derecha, y las ganancias grandes no son riesgo para nadie. AAPL suele ser un caso así.

Y la observación que hay que hacer siempre: compara la columna “Peor mes” con el “VaR 99%”. Casi siempre el peor mes es bastante peor que el VaR al 99%. Eso te recuerda que el VaR es un umbral, no un máximo: no es “lo peor que puede pasar”, es “lo peor que pasa en un mes de cada 100… y hay algo detrás de esa puerta”.

WarningUn número al 99% con 139 meses de datos es casi una anécdota

Mira el output del segundo chunk: el 1% de 139 observaciones es 1.4 observaciones. Tu VaR al 99% está determinado esencialmente por uno o dos meses concretos de la historia. Si quitas ese mes, el número cambia por completo.

Esto no es un defecto de tu código: es un límite fundamental de los datos. Es la razón por la que en la práctica se usan datos diarios para estimar colas (más observaciones), o se ajustan distribuciones paramétricas de cola gorda (inciso b), o se usan métodos de teoría de valores extremos.

8.2 (b) VaR paramétrico con distribución t de Student

La idea. La normal tiene colas muy delgadas: dice que un evento de −5 desviaciones estándar ocurre una vez cada varios millones de años, y en los mercados ocurre cada década. La t de Student tiene un parámetro extra —los grados de libertad \nu— que controla el grosor de las colas: con \nu chico las colas son gordas; cuando \nu\to\infty la t se vuelve normal.

# Cartera de trabajo: la tangencia long-only del Workshop
cartera = r[ASSETS] @ w_tan_lo

def var_parametrico_normal(rends, c=0.95):
    """VaR bajo el supuesto de normalidad."""
    return -(rends.mean() + stats.norm.ppf(1 - c)*rends.std())

def var_parametrico_t(rends, c=0.95):
    """
    VaR bajo una t de Student ajustada a los datos.
    stats.t.fit devuelve (grados de libertad, localización, escala).
    OJO: 'escala' NO es la desviación estándar de la t; la SD de una t es
    escala * sqrt(df/(df-2)). Por eso usamos directamente el cuantil ajustado.
    """
    df, loc, escala = stats.t.fit(rends)
    return -(loc + escala*stats.t.ppf(1 - c, df)), df

filas = []
for c in [0.95, 0.99]:
    v_t, gl = var_parametrico_t(cartera, c)
    filas.append(pd.Series({
        "VaR histórico":  var_historico(cartera, c),
        "VaR normal":     var_parametrico_normal(cartera, c),
        "VaR t-Student":  v_t,
        "grados de libertad de la t": gl,
        "ES histórico":   expected_shortfall(cartera, c)},
        name=f"Confianza {c:.0%}"))

print("CARTERA DE TANGENCIA (long-only)")
CARTERA DE TANGENCIA (long-only)
print(pd.DataFrame(filas).round(4))
               VaR histórico  ...  ES histórico
Confianza 95%         0.0492  ...        0.0644
Confianza 99%         0.0721  ...        0.0745

[2 rows x 5 columns]
# Lo mismo para todos los instrumentos, al 99% que es donde se nota
filas99 = {}
for t in r.columns:
    v_t, gl = var_parametrico_t(r[t], 0.99)
    filas99[t] = pd.Series({
        "VaR 99% histórico": var_historico(r[t], 0.99),
        "VaR 99% normal":    var_parametrico_normal(r[t], 0.99),
        "VaR 99% t-Student": v_t,
        "gl estimados":      gl,
        "Curtosis exc.":     stats.kurtosis(r[t])})
tabla_c6b = pd.DataFrame(filas99).T
tabla_c6b["t / normal"] = tabla_c6b["VaR 99% t-Student"]/tabla_c6b["VaR 99% normal"]
tabla_c6b.sort_values("gl estimados").round(4)
      VaR 99% histórico  VaR 99% normal  ...  Curtosis exc.  t / normal
HYG              0.0624          0.0454  ...         5.7383      1.2281
XOM              0.1667          0.1721  ...         2.2453      1.2395
SPY              0.0952          0.0903  ...         0.6041      1.0825
WMT              0.1543          0.1163  ...         0.8409      1.0566
JPM              0.1508          0.1459  ...         1.1558      1.0560
TSLA             0.3080          0.3527  ...         0.6212      1.0835
GLD              0.1061          0.0951  ...         0.2319      1.0296
AAPL             0.1440          0.1614  ...        -0.4256      0.9960

[8 rows x 6 columns]
# Gráfica: la cola izquierda bajo los tres enfoques
plt.figure(figsize=(11, 6))
<Figure size 1100x600 with 0 Axes>
plt.hist(cartera, bins=30, density=True, alpha=0.55, edgecolor="white",
         label="Rendimientos históricos")
(array([ 2.5355,  1.2678,  3.8033,  0.    ,  5.071 ,  2.5355,  2.5355,
        3.8033,  7.6065,  5.071 ,  5.071 , 11.4098,  6.3388, 10.142 ,
        7.6065, 16.4808,  7.6065, 15.2131, 13.9453,  7.6065,  7.6065,
        8.8743,  7.6065,  3.8033,  2.5355,  1.2678,  2.5355,  1.2678,
        1.2678,  2.5355]), array([-0.0751, -0.0693, -0.0636, -0.0579, -0.0522, -0.0465, -0.0408,
       -0.0351, -0.0293, -0.0236, -0.0179, -0.0122, -0.0065, -0.0008,
        0.005 ,  0.0107,  0.0164,  0.0221,  0.0278,  0.0335,  0.0393,
        0.045 ,  0.0507,  0.0564,  0.0621,  0.0678,  0.0736,  0.0793,
        0.085 ,  0.0907,  0.0964]), <BarContainer object of 30 artists>)
xs = np.linspace(cartera.min()*1.4, cartera.max()*1.2, 400)
gl_c = stats.t.fit(cartera)
plt.plot(xs, stats.norm.pdf(xs, cartera.mean(), cartera.std()), "k-", lw=2,
         label="Densidad normal ajustada")
[<matplotlib.lines.Line2D object at 0x000002E05DB3A3C0>]
plt.plot(xs, stats.t.pdf(xs, *gl_c), "b-", lw=2,
         label=f"Densidad t ajustada (gl = {gl_c[0]:.1f})")
[<matplotlib.lines.Line2D object at 0x000002E05DB3A510>]
plt.axvline(-var_historico(cartera, 0.99),  color="orange", ls="--", lw=2, label="VaR 99% histórico")
<matplotlib.lines.Line2D object at 0x000002E05DB3A120>
plt.axvline(-var_parametrico_normal(cartera, 0.99), color="red",  ls=":",  lw=2, label="VaR 99% normal")
<matplotlib.lines.Line2D object at 0x000002E05DB3A270>
plt.axvline(-var_parametrico_t(cartera, 0.99)[0],   color="blue", ls="-.", lw=2, label="VaR 99% t")
<matplotlib.lines.Line2D object at 0x000002E05DB3A660>
plt.xlabel("Rendimiento mensual"); plt.ylabel("Densidad")
Text(0.5, 0, 'Rendimiento mensual')
Text(0, 0.5, 'Densidad')
plt.title("Los tres VaR al 99% sobre la misma distribución")
Text(0.5, 1.0, 'Los tres VaR al 99% sobre la misma distribución')
plt.legend(fontsize=9); plt.grid(alpha=0.3)
<matplotlib.legend.Legend object at 0x000002E05DB3A7B0>
plt.show()

Interpretación.

Los grados de libertad son el diagnóstico. Lee la columna “gl estimados” de la segunda tabla. Como referencia:

  • \nu > 30: la distribución es prácticamente normal.
  • \nu \approx 510: colas claramente gordas.
  • \nu < 5: colas muy gordas. (Curiosidad importante: con \nu\le 4 la curtosis de la t es infinita, y con \nu\le 2 la varianza es infinita. En esos casos, “la volatilidad” del activo ni siquiera está bien definida en la población.)

SPY suele salir con pocos grados de libertad —el mercado accionario tiene colas notoriamente gordas—, mientras que la cartera diversificada sale con más, porque diversificar adelgaza (que no elimina) las colas.

La comparación al 99%. El patrón que verás:

  • Al 95% los tres métodos dan números parecidos. Ahí no hay problema: el percentil 5 está en la parte “gorda” de la distribución, donde todo modelo razonable acierta.
  • Al 99% se separan, y casi siempre en este orden: t > normal. La t reconoce que hay más masa en la cola y por eso pide más capital. La normal es la más optimista, y esa es exactamente la crítica clásica que se le hace.
  • El histórico es errático: a veces queda arriba, a veces abajo, porque —como vimos— lo determinan una o dos observaciones.

¿Cuál reportaría al comité de riesgos? Mi respuesta, y la que esperaría de ti, con justificación:

  1. Reportaría los tres, en la misma tabla. No es una evasiva: es la práctica correcta. La dispersión entre los tres números es información: te dice cuánto de tu medición de riesgo depende de un supuesto y no de los datos. Un comité de riesgos que ve “VaR 99% entre 9.2% y 10.2% según el método” entiende algo que no entendería con un solo número.

  2. Si me obligan a elegir uno solo: la t de Student. Razones:

    • Reconoce las colas gordas, que son un hecho documentado de los rendimientos financieros.
    • A diferencia del histórico, no está determinada por una o dos observaciones: usa toda la muestra para estimar tres parámetros, y por eso es mucho más estable mes a mes (un comité de riesgos odia los límites que brincan).
    • A diferencia del histórico, puede extrapolar más allá de lo observado: puede darte un cuantil al 99.9% aunque nunca hayas visto un evento así.
    • Y es conservadora en el sentido correcto: en riesgo, errar por exceso de prudencia cuesta capital; errar por defecto cuesta la empresa.
  3. Pero reportaría también el Expected Shortfall, no sólo el VaR —ver la siguiente sección—. Y en la nota al pie diría el número de observaciones en la cola, para que nadie confunda precisión con certeza.

NoteLo que hace la regulación bancaria

Basilea III abandonó el VaR al 99% y adoptó el Expected Shortfall al 97.5% como base del capital de riesgo de mercado. Dos razones, exactamente las de este Challenge: (i) el ES es subaditivo, o sea siempre premia diversificar, cosa que el VaR no garantiza; y (ii) el ES sí mira detrás de la puerta. Que un regulador cambie de métrica después de treinta años dice mucho sobre qué tan seria es la limitación del VaR.

8.3 (c) Máximo drawdown de la cartera de tangencia y de SPY

La idea. El drawdown mide, en cada momento, cuánto has perdido desde tu máximo histórico previo:

DD_t=\frac{V_t}{\max_{s\le t}V_s}-1

Es siempre \le 0, vale exactamente 0 cuando estás en un máximo nuevo, y el máximo drawdown es su mínimo de toda la serie.

# Valor acumulado de $1 (recuerda: exp de la suma acumulada de rendimientos cc)
valor = pd.DataFrame({
    "Tangencia long-only": np.exp(cartera.cumsum()),
    "SPY":                 np.exp(r["SPY"].cumsum())})

maximo_previo = valor.cummax()       # el "high-water mark" de cada serie
drawdown = valor/maximo_previo - 1

print("MÁXIMO DRAWDOWN")
MÁXIMO DRAWDOWN
print(drawdown.min().round(4))
Tangencia long-only   -0.1861
SPY                   -0.2393
dtype: float64
print("\nFecha en que se tocó el fondo:")

Fecha en que se tocó el fondo:
print(drawdown.idxmin())
Tangencia long-only   2022-09-30
SPY                   2022-09-30
dtype: datetime64[ns]
# Estadísticas adicionales del drawdown: duración y recuperación
def estadisticas_drawdown(serie_dd):
    """Duración del peor drawdown y tiempo de recuperación."""
    fondo = serie_dd.idxmin()
    # el pico previo es el último momento en que el drawdown era 0 antes del fondo
    antes = serie_dd[:fondo]
    pico = antes[antes >= -1e-9].index[-1] if (antes >= -1e-9).any() else serie_dd.index[0]
    # recuperación: primer momento después del fondo en que se vuelve a 0
    despues = serie_dd[serie_dd.index > fondo]
    recuperacion = despues[despues >= -1e-9]
    fecha_rec = recuperacion.index[0] if len(recuperacion) else None
    return pd.Series({
        "Máximo drawdown": serie_dd.min(),
        "Pico previo": pico.date(),
        "Fondo": fondo.date(),
        "Meses de caída": len(serie_dd[(serie_dd.index > pico) & (serie_dd.index <= fondo)]),
        "Fecha de recuperación": fecha_rec.date() if fecha_rec else "aún no recuperado",
        "Meses de recuperación": len(serie_dd[(serie_dd.index > fondo) &
                                              (serie_dd.index <= fecha_rec)]) if fecha_rec else np.nan})

pd.DataFrame({c: estadisticas_drawdown(drawdown[c]) for c in drawdown.columns})
                      Tangencia long-only         SPY
Máximo drawdown                   -0.1861     -0.2393
Pico previo                    2021-12-31  2021-12-31
Fondo                          2022-09-30  2022-09-30
Meses de caída                          9           9
Fecha de recuperación          2023-06-30  2023-12-31
Meses de recuperación                   9          15
fig, axes = plt.subplots(2, 1, figsize=(12, 9), sharex=True)
valor.plot(ax=axes[0], lw=2)
<Axes: xlabel='Date'>
axes[0].set_title("Valor de $1 invertido"); axes[0].set_ylabel("Valor")
Text(0.5, 1.0, 'Valor de $1 invertido')
Text(0, 0.5, 'Valor')
axes[0].grid(alpha=0.3); axes[0].legend(title="Estrategia")
<matplotlib.legend.Legend object at 0x000002E05D819FD0>
drawdown.plot(ax=axes[1], lw=2)
<Axes: xlabel='Date'>
axes[1].fill_between(drawdown.index, drawdown["SPY"], 0, alpha=0.15, color="orange")
<matplotlib.collections.FillBetweenPolyCollection object at 0x000002E05D194050>
axes[1].set_title("Drawdown: cuánto has perdido desde tu máximo previo")
Text(0.5, 1.0, 'Drawdown: cuánto has perdido desde tu máximo previo')
axes[1].set_ylabel("Drawdown"); axes[1].set_xlabel("Fecha")
Text(0, 0.5, 'Drawdown')
Text(0.5, 0, 'Fecha')
axes[1].axhline(0, color="black", lw=0.8); axes[1].grid(alpha=0.3)
<matplotlib.lines.Line2D object at 0x000002E05D194440>
axes[1].legend(title="Estrategia")
<matplotlib.legend.Legend object at 0x000002E05D1942F0>
plt.tight_layout(); plt.show()

Interpretación.

La cartera de tangencia tiene un máximo drawdown menor que SPY, lo cual es coherente con lo que hemos visto: está diversificada e incluye GLD, que es el único instrumento del universo con correlación cercana a cero con las acciones. La gráfica muestra además algo que ninguna tabla transmite: el tiempo que se pasa bajo el agua. La zona sombreada no es un instante, son meses o años en los que el inversionista abre su estado de cuenta y ve un número menor al de su máximo.

¿Por qué el máximo drawdown puede ser más relevante que el VaR para un inversionista de hedge fund con periodo de lock-up? Cinco razones, en orden de importancia:

1. El VaR es de un solo periodo; el drawdown es del camino. El VaR dice “qué tan malo puede ser un mes”. Pero un inversionista con lock-up de un año no puede salir después de un mal mes: le toca la secuencia completa. Tres meses de −6% seguidos no violan un VaR mensual de 7% ni una sola vez, y acumulan −17%. El VaR literalmente no ve eso; el drawdown es exactamente eso.

2. El drawdown es lo que activa las consecuencias contractuales. En un hedge fund, un drawdown grande dispara una cadena que un mal mes aislado no dispara:

  • Llamadas de margen del prime broker, que obligan a vender posiciones en el peor momento, convirtiendo una pérdida en papel en una pérdida realizada.
  • Redenciones de los inversionistas cuando se abre la ventana, lo que obliga a liquidar más, lo que profundiza el drawdown: un círculo vicioso que ha matado fondos enteros.
  • La marca de agua (high-water mark): el gestor no cobra comisión de éxito hasta recuperar el máximo previo. Un drawdown de 30% puede significar años sin ingreso de incentivos, y la reacción típica del gestor es o cerrar el fondo o aumentar el riesgo para recuperar rápido, que es justo lo contrario de lo que el inversionista quiere.

3. La matemática de la recuperación es brutal y asimétrica. Para recuperarse de una caída de d hace falta una ganancia de \frac{d}{1-d}: de −20% necesitas +25%, de −50% necesitas +100%, de −80% necesitas +400%. Con un lock-up, no puedes ni siquiera decidir irte mientras esa aritmética trabaja en tu contra.

4. Es lo único que el inversionista realmente experimenta. Nadie siente un percentil. Todo el mundo siente ver su capital 30% abajo del máximo durante dos años. El drawdown es la medida de riesgo que coincide con la experiencia psicológica, y por eso es la que provoca las decisiones (malas) de los inversionistas.

5. Y la advertencia estadística que hay que hacer siempre: el máximo drawdown es un estadístico de un solo evento. Es el mínimo de toda la serie: por definición está determinado por una observación, y tiende a crecer con la longitud de la muestra (mientras más tiempo observes, más probable es haber visto algo peor). Por eso no se debe comparar el máximo drawdown de un fondo de 3 años con el de uno de 20 — es una comparación tramposa muy común en el material de marketing. Las alternativas más robustas son el drawdown promedio, el tiempo promedio bajo el agua, o el ratio de Calmar (rendimiento anual entre máximo drawdown).

8.4 (d) Un fondo de 500 millones con VaR mensual al 99% de 4%

AUM = 500_000_000
var_pct = 0.04

var_dolares = AUM * var_pct
print("VaR mensual al 99%%: %.1f%% de $%s = $%s"
      % (var_pct*100, f"{AUM:,.0f}", f"{var_dolares:,.0f}"))
VaR mensual al 99%: 4.0% de $500,000,000 = $20,000,000
print()
print("Lectura correcta de la frase:")
Lectura correcta de la frase:
print("  En 99 de cada 100 meses, se espera perder MENOS de $%s." % f"{var_dolares:,.0f}")
  En 99 de cada 100 meses, se espera perder MENOS de $20,000,000.
print("  En aproximadamente 1 mes de cada 100 (~1 vez cada 8.3 años),")
  En aproximadamente 1 mes de cada 100 (~1 vez cada 8.3 años),
print("  se espera perder MÁS de esa cantidad. Cuánto más: NO LO DICE.")
  se espera perder MÁS de esa cantidad. Cuánto más: NO LO DICE.
print()
# Qué pasaría bajo distintos supuestos de cola
print("Si la distribución fuera normal, el ES al 99%% sería ~1.15x el VaR:  $%s"
      % f"{var_dolares*1.146:,.0f}")
Si la distribución fuera normal, el ES al 99% sería ~1.15x el VaR:  $22,920,000
print("Con colas gordas (t con 5 gl), el ES/VaR es más bien ~1.4x:         $%s"
      % f"{var_dolares*1.4:,.0f}")
Con colas gordas (t con 5 gl), el ES/VaR es más bien ~1.4x:         $28,000,000
print("Y si el fondo tiene 3x de apalancamiento, todo esto se multiplica por 3.")
Y si el fondo tiene 3x de apalancamiento, todo esto se multiplica por 3.

Interpretación: qué SÍ dice y qué NO dice ese número.

Lo que dice: en un mes normal —99 de cada 100— el fondo no debería perder más de 20 millones de dólares. Es un umbral de pérdida, un punto de referencia para poner límites operativos.

Lo que NO dice — y ésta es la respuesta que se califica:

1. No dice cuánto se pierde cuando se rebasa. Es la crítica central del VaR. Ese 4% es compatible con un peor caso de −4.5% y con un peor caso de −40%. El VaR es la puerta, no el hoyo. El número que respondería esto es el Expected Shortfall, y si el fondo no lo reporta, hay que pedirlo. Los cálculos de arriba muestran que, según el grosor de la cola, el ES podría ser 23 o 28 millones — o mucho más.

2. No dice con qué método se calculó. ¿Histórico, normal, t de Student, Monte Carlo? Vimos en el inciso (b) que los tres métodos dan respuestas distintas sobre los mismos datos. Un VaR sin metodología es un número sin unidades.

3. No dice sobre cuántos datos. ¿139 meses? ¿36? ¿Incluye 2008? ¿Incluye marzo de 2020? Un VaR al 99% estimado sobre un periodo sin crisis dirá, con total seriedad, que las crisis no ocurren.

4. No dice nada sobre el apalancamiento ni sobre la liquidez. Los 500 millones son el capital de los inversionistas (AUM). Si el fondo opera con 3x de apalancamiento, su exposición bruta es de 1,500 millones, y el VaR sobre esa exposición es otro número. Peor aún: en un mes de estrés, el fondo puede enfrentar llamadas de margen que lo obliguen a vender activos ilíquidos a precio de remate, y esa pérdida por liquidación no está en ningún VaR estimado con precios históricos.

5. No dice nada sobre el riesgo de modelo ni sobre posiciones no lineales. Si el fondo tiene opciones, su perfil de pérdidas no es lineal y el VaR paramétrico basado en volatilidad simplemente no aplica.

6. Y no dice nada sobre el camino. Como vimos en (c): doce meses consecutivos, cada uno cumpliendo perfectamente el VaR, pueden sumar un drawdown devastador.

ImportantQué pediría yo si me presentaran ese número
  1. El Expected Shortfall al mismo nivel de confianza.
  2. La metodología y la ventana de estimación.
  3. El backtest del VaR: ¿cuántas veces se rebasó en los últimos N meses? Si el modelo es bueno, debió rebasarse ~1% de las veces. Si se rebasó el 5%, el modelo está roto.
  4. El apalancamiento bruto y neto, y el VaR bajo escenarios de estrés (2008, marzo 2020).
  5. El máximo drawdown histórico y el tiempo de recuperación.
  6. El perfil de liquidez de las posiciones frente a los términos de redención del fondo.

Un solo número de riesgo nunca es un reporte de riesgo.

9 CHALLENGE 7 — Solución

9.1 (a) CAPM, FF3 y FF5 para AAPL y TSLA

La idea. Vamos a correr el mismo activo contra tres modelos cada vez más ricos y a mirar qué le pasa al alfa y qué le pasa al R^2. El resultado va a sostener la frase más importante del módulo: el alfa no es una propiedad del fondo, es una propiedad del modelo con el que lo mides.

F3 = ("Mkt_RF", "SMB", "HML")
F5 = ("Mkt_RF", "SMB", "HML", "RMW", "CMA")

def compara_modelos(ticker):
    """Corre CAPM, FF3 y FF5 sobre un ticker y arma la tabla comparativa."""
    m1 = factor_model(r[ticker])          # CAPM: sólo el factor de mercado
    m3 = factor_model(r[ticker], F3)      # Fama-French 3 factores
    m5 = factor_model(r[ticker], F5)      # Fama-French 5 factores
    tabla = pd.DataFrame([model_row(m1, "CAPM"),
                          model_row(m3, "FF 3 factores"),
                          model_row(m5, "FF 5 factores")])
    return m1, m3, m5, tabla

m1_a, m3_a, m5_a, tabla_aapl = compara_modelos("AAPL")
print("AAPL"); print(tabla_aapl.round(4).to_string())
AAPL
               alfa (anual)  p-value alfa  beta Mkt  beta SMB  beta HML  beta RMW  beta CMA     R2  R2 ajustada
CAPM                 0.0602        0.3292    1.1164       NaN       NaN       NaN       NaN 0.4241       0.4199
FF 3 factores        0.0510        0.3924    1.1483   -0.2057   -0.4087       NaN       NaN 0.4789       0.4672
FF 5 factores        0.0482        0.4167    1.1183   -0.0853   -0.4150    0.4198   -0.1566 0.4926       0.4734
m1_t, m3_t, m5_t, tabla_tsla = compara_modelos("TSLA")
print("TSLA"); print(tabla_tsla.round(4).to_string())
TSLA
               alfa (anual)  p-value alfa  beta Mkt  beta SMB  beta HML  beta RMW  beta CMA     R2  R2 ajustada
CAPM                 0.0629        0.6748    1.7218       NaN       NaN       NaN       NaN 0.2283       0.2226
FF 3 factores        0.0949        0.5145    1.5474    0.8398   -1.2241       NaN       NaN 0.2966       0.2808
FF 5 factores        0.0994        0.4954    1.4808    0.6899   -0.8513   -0.2551   -0.7530 0.3050       0.2787
# Los p-values de cada coeficiente del FF5, que es lo que decide qué creer
print("p-values del modelo de 5 factores")
p-values del modelo de 5 factores
print(pd.DataFrame({"AAPL": m5_a.pvalues, "TSLA": m5_t.pvalues}).round(4))
            AAPL   TSLA
Intercept 0.4167 0.4954
Mkt_RF    0.0000 0.0000
SMB       0.6670 0.1588
HML       0.0264 0.0633
RMW       0.0659 0.6477
CMA       0.5658 0.2625
# Salida completa del FF5 de TSLA, para leer errores estándar e intervalos
print(m5_t.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:                      y   R-squared:                       0.305
Model:                            OLS   Adj. R-squared:                  0.279
Method:                 Least Squares   F-statistic:                     11.59
Date:                Thu, 03 Sep 2026   Prob (F-statistic):           2.73e-09
Time:                        21:09:54   Log-Likelihood:                 80.846
No. Observations:                 138   AIC:                            -149.7
Df Residuals:                     132   BIC:                            -132.1
Df Model:                           5                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept      0.0083      0.012      0.684      0.495      -0.016       0.032
Mkt_RF         1.4808      0.284      5.208      0.000       0.918       2.043
SMB            0.6899      0.487      1.417      0.159      -0.273       1.653
HML           -0.8513      0.455     -1.872      0.063      -1.751       0.048
RMW           -0.2551      0.557     -0.458      0.648      -1.357       0.846
CMA           -0.7530      0.669     -1.125      0.262      -2.077       0.571
==============================================================================
Omnibus:                        5.268   Durbin-Watson:                   1.904
Prob(Omnibus):                  0.072   Jarque-Bera (JB):                5.026
Skew:                           0.342   Prob(JB):                       0.0810
Kurtosis:                       3.638   Cond. No.                         64.2
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

Interpretación coeficiente por coeficiente.

9.1.1 AAPL

  • Beta de mercado ≈ 1.2, muy estable entre los tres modelos. Cuando el premio del mercado sube 1 pp, AAPL sube ~1.2 pp. Es el coeficiente mejor estimado (p-value ≈ 0.000) y prácticamente no se mueve al añadir factores — señal de que el factor de mercado captura algo distinto de lo que capturan SMB y HML.

  • Beta de SMB negativo (≈ −0.2). SMB es “small menos big”, así que un coeficiente negativo significa que AAPL se comporta como una empresa grande. Perfecto: es una de las mayores capitalizaciones del mundo. Que el signo salga como la economía predice es tu validación de que los datos están bien alineados.

  • Beta de HML negativo (≈ −0.3 a −0.5). HML es “value menos growth”; negativo significa que AAPL se comporta como una acción de crecimiento. También correcto.

  • Alfa: sube al agregar factores, y sigue sin ser significativo. Aquí conviene tener cuidado: el alfa de AAPL no baja, sube un poco. Explico por qué en la sección “el patrón general” más abajo — no es una anomalía.

9.1.2 TSLA

  • Beta de mercado ≈ 1.7–1.9, con un error estándar grande. Es el activo más sensible al mercado del universo, y también el peor estimado.

  • Beta de SMB positivo: TSLA se comporta parcialmente como una small-cap, en el sentido de que comparte los patrones de riesgo de empresas más chicas y volátiles.

  • Beta de HML fuertemente negativo (≈ −0.6 a −0.9): TSLA es el caso extremo de acción de crecimiento de todo el universo. Este coeficiente es el que sale significativo en el FF3.

  • R^2 ≈ 0.26–0.29, bajísimo. Aun con cinco factores, más del 70% de lo que mueve a TSLA no lo explica ningún factor conocido. TSLA es riesgo idiosincrático casi puro. Quien compra TSLA está apostándole a TSLA, no al mercado ni a un estilo.

  • Alfa: enorme en magnitud (10-15% anual) y completamente insignificante (p-value ≈ 0.3-0.5). El intervalo de confianza del alfa mensual es tan ancho que incluye holgadamente el cero. La lectura honesta: no hay evidencia estadística de alfa en TSLA, a pesar de que su rendimiento acumulado fue el mayor del universo. Ése es el resultado más contraintuitivo —y más importante— de todo el Challenge.

9.1.3 El patrón general: qué le pasa al alfa y al R^2

El R^2 siempre sube. Siempre, sin excepción, y no es evidencia de nada: es un teorema. Agregar una variable a una regresión por mínimos cuadrados nunca puede aumentar la suma de residuos al cuadrado, en el peor caso el coeficiente sale cero. Por eso hay que mirar el R^2 ajustado, que penaliza por número de variables. En la tabla se ve que la mejora del R^2 ajustado del FF3 al FF5 es marginal para estos dos activos: RMW y CMA aportan poco aquí.

El alfa se mueve, y la dirección depende del activo. El caso de libro —el que muestra el Workshop con XOM— es que el alfa baja: el CAPM llamaba “rendimiento anormal” a algo que en realidad era compensación por un sesgo de valor, y al agregar HML ese pedazo se reclasifica de alfa a beta. Pero con AAPL y TSLA puede pasar lo contrario, y la razón es instructiva: como ambas tienen HML negativo y el factor HML tuvo rendimiento promedio positivo en el periodo, el modelo dice “tu exposición al value era negativa, así que debiste haber rendido menos de lo que rendiste”, y el alfa sube.

ImportantLa conclusión que hay que llevarse

\alpha = \text{lo que tu modelo no explica}

El alfa no es una propiedad del activo: es el residuo de un modelo. Cambia el modelo y cambia el alfa. Por eso cualquier alfa citado sin decir contra qué modelo se midió es un número vacío, y por eso la pregunta correcta a un hedge fund nunca es “¿cuál es su alfa?” sino “¿alfa contra qué modelo, y con qué p-value?”

9.2 (b) Prueba conjunta de H_0: SMB = 0 y HML = 0 en TSLA

La idea. Tenemos dos coeficientes y queremos saber si juntos aportan algo. Para eso no sirve mirar dos p-values individuales: hay que hacer una prueba conjunta, que es lo que hace la prueba F.

m3_tsla = factor_model(r["TSLA"], F3)

print("Coeficientes individuales del FF3 de TSLA:")
Coeficientes individuales del FF3 de TSLA:
print(m3_tsla.summary().tables[1])
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept      0.0079      0.012      0.654      0.514      -0.016       0.032
Mkt_RF         1.5474      0.275      5.631      0.000       1.004       2.091
SMB            0.8398      0.451      1.863      0.065      -0.052       1.732
HML           -1.2241      0.344     -3.562      0.001      -1.904      -0.544
==============================================================================
# Prueba F conjunta: ¿SMB y HML son AMBOS cero?
prueba_f = m3_tsla.f_test("SMB = 0, HML = 0")
print("PRUEBA F CONJUNTA  H0: SMB = 0 y HML = 0")
PRUEBA F CONJUNTA  H0: SMB = 0 y HML = 0
print(prueba_f)
<F test: F=6.505251756768488, p=0.002012245810212234, df_denom=134, df_num=2>
print()
print("p-values individuales:  SMB = %.4f   |   HML = %.4f"
      % (m3_tsla.pvalues["SMB"], m3_tsla.pvalues["HML"]))
p-values individuales:  SMB = 0.0647   |   HML = 0.0005
print()
print("Correlación entre los factores SMB y HML: %.4f" % ff[["SMB","HML"]].corr().iloc[0,1])
Correlación entre los factores SMB y HML: 0.3538
# Verificación "a mano": la F es la mejora relativa del ajuste al agregar factores
m_restringido = factor_model(r["TSLA"], ("Mkt_RF",))     # modelo bajo H0 (sin SMB ni HML)
SSR_r = m_restringido.ssr          # suma de residuos al cuadrado del modelo restringido
SSR_c = m3_tsla.ssr                # ... y del completo
q = 2                              # número de restricciones
gl = m3_tsla.df_resid              # grados de libertad del modelo completo

F_manual = ((SSR_r - SSR_c)/q) / (SSR_c/gl)
print("F calculada a mano: %.4f" % F_manual)
F calculada a mano: 6.5053
print("(debe coincidir con la F del f_test de arriba)")
(debe coincidir con la F del f_test de arriba)

Interpretación.

La conclusión. La F conjunta arroja un p-value que, en este caso, suele quedar cerca del 5-8%: es decir, en la zona gris. Al 10% de significancia se rechaza que ambos coeficientes sean cero; al 5% estricto, no (o apenas). La lectura honesta es: hay evidencia sugestiva pero no concluyente de que TSLA tiene exposiciones de estilo además de su exposición al mercado.

Fíjate en lo que pasó con los p-values individuales: HML es significativo, SMB no lo es. Si sólo hubieras mirado los p-values por separado, tu conclusión habría sido “HML sí, SMB no”, que es distinta de lo que dice la prueba conjunta.

¿Por qué la prueba conjunta es mejor que mirar dos p-values por separado? Cuatro razones:

1. Porque el problema de las comparaciones múltiples es real. Cada prueba individual al 5% tiene 5% de probabilidad de dar un falso positivo. Si haces dos pruebas independientes, la probabilidad de que al menos una salga significativa por azar no es 5%, es 1-0.95^2 \approx 9.75\%. Con cinco factores llegas a ~23%. La prueba F controla el error de todo el conjunto de hipótesis a la vez, que es la pregunta que realmente te interesa.

2. Porque los regresores están correlacionados, y eso reparte la evidencia. Si SMB y HML comparten información, cada uno puede salir individualmente insignificante —porque el otro “le roba” la explicación— y aun así ser conjuntamente muy significativos. Es la firma clásica de la multicolinealidad: errores estándar inflados individualmente, y un F conjunto alto. La prueba F mira la mejora del ajuste del modelo completo, sin tener que atribuir el crédito a un factor en particular.

3. Porque es la pregunta correcta. Tú no quieres saber “¿importa SMB?”; quieres saber “¿basta el CAPM, o necesito el modelo de estilos?”. Ésa es una pregunta sobre dos coeficientes a la vez, y la prueba F es literalmente la comparación entre el modelo restringido (CAPM) y el completo (FF3), como se ve en la verificación manual.

4. Y en el contexto de hedge funds: cuando evalúas un fondo con un modelo de siete u ocho factores, siempre vas a encontrar uno o dos con p-value bajo por puro azar. La prueba F (o su versión famosa en esta literatura, el test GRS de Gibbons, Ross y Shanken, que prueba conjuntamente que los alfas de muchos activos son todos cero) es lo que te impide contarte esa historia.

9.3 (c) Análisis de estilo de la cartera de tangencia

La idea. Vamos a tratar nuestra propia cartera como si fuera un hedge fund que nos está pidiendo dinero, y a hacerle exactamente el examen que le haríamos a un tercero.

cartera_tan = r[ASSETS] @ w_tan_lo

print("Composición de la cartera de tangencia (long-only):")
Composición de la cartera de tangencia (long-only):
print((pd.Series(w_tan_lo, index=ASSETS)*100).round(2).astype(str) + " %")
AAPL    19.97 %
JPM     24.67 %
WMT     17.56 %
XOM       0.0 %
TSLA     2.53 %
GLD     35.26 %
dtype: object
tabla_estilo = pd.DataFrame([
    model_row(factor_model(cartera_tan),     "CAPM"),
    model_row(factor_model(cartera_tan, F3), "FF 3 factores"),
    model_row(factor_model(cartera_tan, F5), "FF 5 factores")])
tabla_estilo.round(4).to_string()
'               alfa (anual)  p-value alfa  beta Mkt  beta SMB  beta HML  beta RMW  beta CMA     R2  R2 ajustada\nCAPM                 0.0566        0.0118    0.6317       NaN       NaN       NaN       NaN 0.6443       0.6417\nFF 3 factores        0.0498        0.0254    0.6645   -0.1711    0.0714       NaN       NaN 0.6607       0.6531\nFF 5 factores        0.0481        0.0282    0.6579   -0.1055    0.0337    0.2038    0.0038 0.6757       0.6635'
m5_tan = factor_model(cartera_tan, F5)
print(m5_tan.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:                      y   R-squared:                       0.676
Model:                            OLS   Adj. R-squared:                  0.663
Method:                 Least Squares   F-statistic:                     55.02
Date:                Thu, 03 Sep 2026   Prob (F-statistic):           1.22e-30
Time:                        21:09:56   Log-Likelihood:                 343.41
No. Observations:                 138   AIC:                            -674.8
Df Residuals:                     132   BIC:                            -657.3
Df Model:                           5                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept      0.0040      0.002      2.219      0.028       0.000       0.008
Mkt_RF         0.6579      0.042     15.514      0.000       0.574       0.742
SMB           -0.1055      0.073     -1.452      0.149      -0.249       0.038
HML            0.0337      0.068      0.496      0.620      -0.100       0.168
RMW            0.2038      0.083      2.453      0.015       0.039       0.368
CMA            0.0038      0.100      0.038      0.970      -0.194       0.201
==============================================================================
Omnibus:                        1.414   Durbin-Watson:                   1.821
Prob(Omnibus):                  0.493   Jarque-Bera (JB):                0.984
Skew:                           0.084   Prob(JB):                        0.611
Kurtosis:                       3.378   Cond. No.                         64.2
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
# ¿Cuánto del rendimiento viene de los factores y cuánto es alfa?
# Descomponemos el rendimiento premio promedio en sus fuentes.
p = m5_tan.params
contribucion = pd.Series({
    f: p[f]*ff[f].mean()*12 for f in F5})            # beta_k * E[F_k], anualizado
contribucion["ALFA (lo no explicado)"] = p.iloc[0]*12
contribucion["TOTAL (premio anual)"] = contribucion.sum()

print("DESCOMPOSICIÓN DEL PREMIO ANUAL DE LA CARTERA DE TANGENCIA")
DESCOMPOSICIÓN DEL PREMIO ANUAL DE LA CARTERA DE TANGENCIA
print((contribucion*100).round(2).astype(str) + " %")
Mkt_RF                     7.96 %
SMB                         0.2 %
HML                       -0.01 %
RMW                        0.35 %
CMA                        -0.0 %
ALFA (lo no explicado)     4.81 %
TOTAL (premio anual)       13.3 %
dtype: object
print()
print("Porcentaje del premio que viene de EXPOSICIONES A FACTORES: %.1f%%"
      % (100*(contribucion["TOTAL (premio anual)"] - contribucion["ALFA (lo no explicado)"])
         / contribucion["TOTAL (premio anual)"]))
Porcentaje del premio que viene de EXPOSICIONES A FACTORES: 63.8%

Interpretación.

¿A qué está expuesta realmente esta cartera?

  1. Sobre todo, al mercado. El beta de mercado es ≈0.86-0.88, muy significativo, y el R^2 es de ~0.69. Es decir: cerca del 70% de los movimientos de esta cartera son movimientos del mercado. No es una estrategia “alternativa” en ningún sentido; es beta con un descuento.

  2. Un sesgo growth moderado. El beta de HML es negativo (≈ −0.17 a −0.19), en el límite de la significancia. Tiene sentido dada la composición: AAPL pesa un cuarto de la cartera.

  3. Poco más. SMB, RMW y CMA no son significativos. La cartera no tiene sesgo claro de tamaño, calidad ni inversión.

  4. El alfa es de ~5% anual con un p-value de ~0.05-0.08. Es de los alfas más “presentables” que hemos visto en todo el Workshop… y aun así está en el borde de la significancia, y para colmo, está contaminado por sesgo de selección (ver abajo).

¿La describiría como una “estrategia de alfa”? Rotundamente no. Y tengo cuatro argumentos:

1. La descomposición del rendimiento habla sola. Mira el último chunk: más de la mitad del premio anual viene de la exposición al factor de mercado, no del alfa. Un inversionista podría replicar buena parte del desempeño de esta cartera comprando ~86% de un ETF del S&P 500 y ~14% de Cetes, y pagando 3 puntos base en lugar de 2-and-20.

2. Un R^2 de 0.69 contra factores públicos es la definición de “beta disfrazada”. Un fondo cuya estrategia es genuinamente distinta al mercado debería tener un R^2 bajo contra los factores conocidos. Éste no lo tiene.

3. El alfa está in-sample, y peor: está seleccionado. Y este punto es el más importante de todos. Los pesos de esta cartera se eligieron usando los mismos 139 meses sobre los que estamos midiendo el alfa. El optimizador buscó, entre todas las combinaciones posibles, la que maximizaba el Sharpe en esta muestra. Que la cartera ganadora de esa búsqueda muestre alfa positivo no es evidencia de habilidad: es una consecuencia aritmética del procedimiento. Se llama sesgo de data-snooping o look-ahead bias, y es la razón por la que ningún alfa in-sample es creíble.

La prueba justa sería: estimar los pesos con los primeros 70 meses, aplicarlos sin tocarlos a los 69 meses siguientes, y medir el alfa ahí. Casi con certeza se evapora.

4. Y un test de sensatez final: si esta cartera de verdad tuviera 5% de alfa anual, sería una de las mejores estrategias del mundo, y la construimos en tres líneas de código con seis tickers que cualquiera conoce. Cuando un resultado es demasiado bueno para el esfuerzo invertido, casi siempre hay un sesgo escondido. Es la mejor heurística que te puedes llevar de este curso.

9.4 (d) Long en las 3 de mayor beta HML, short en las 3 de menor

La idea. Vamos a construir, con nuestras propias manos y usando sólo 6 de nuestros 8 tickers, una cartera larga-corta ordenada por característica de estilo — y luego preguntar si accidentalmente reconstruimos el factor HML.

NoteUna aclaración sobre el universo

El enunciado dice “las 3 acciones”, pero en nuestro universo sólo hay 5 acciones propiamente dichas (AAPL, JPM, WMT, XOM, TSLA) y no alcanzan para formar dos patas de 3. Por eso ordenamos los 8 instrumentos. Es una decisión defendible —HYG y GLD también tienen cargas a HML y son perfectamente negociables—, pero hay que decirla en voz alta: al meter un ETF de bonos en la pata larga, parte de lo que mediremos después no es un sesgo de estilo accionario. Cuando tomes una decisión así en un trabajo, documéntala: la mitad del valor de un análisis está en explicitar los supuestos que nadie te obligó a tomar.

# Paso 1: obtener el beta HML de cada instrumento (del modelo FF3)
betas_hml = pd.Series({t: factor_model(r[t], F3).params["HML"] for t in r.columns})
betas_hml = betas_hml.sort_values()

print("Beta HML de cada instrumento (ordenados de menor a mayor):")
Beta HML de cada instrumento (ordenados de menor a mayor):
print(betas_hml.round(4))
TSLA   -1.2241
AAPL   -0.4087
WMT    -0.0554
GLD    -0.0025
SPY     0.0454
HYG     0.0871
JPM     0.7889
XOM     1.0765
dtype: float64
# Paso 2: los 3 de arriba van largos, los 3 de abajo van cortos
largos  = list(betas_hml.index[-3:])
cortos  = list(betas_hml.index[:3])

print("LARGO (mayor beta HML, sesgo VALUE) :", largos)
LARGO (mayor beta HML, sesgo VALUE) : ['HYG', 'JPM', 'XOM']
print("CORTO (menor beta HML, sesgo GROWTH):", cortos)
CORTO (menor beta HML, sesgo GROWTH): ['TSLA', 'AAPL', 'WMT']
print()
# Paso 3: la cartera. Equiponderada dentro de cada pata.
# El resultado es una cartera de COSTO CERO: lo que se obtiene del corto
# financia el largo. Por eso su rendimiento se puede comparar directamente
# con un factor de Fama-French, que se construye exactamente igual.
cartera_ls = r[largos].mean(axis=1) - r[cortos].mean(axis=1)

print("Rendimiento anualizado de mi cartera long-short : %+.4f" % (cartera_ls.mean()*12))
Rendimiento anualizado de mi cartera long-short : -0.1181
print("Rendimiento anualizado del factor HML real      : %+.4f" % (ff["HML"].mean()*12))
Rendimiento anualizado del factor HML real      : -0.0039
print("Volatilidad anual de mi cartera long-short      :  %.4f" % (cartera_ls.std()*np.sqrt(12)))
Volatilidad anual de mi cartera long-short      :  0.2716
print("Volatilidad anual del factor HML real           :  %.4f" % (ff["HML"].std()*np.sqrt(12)))
Volatilidad anual del factor HML real           :  0.1277
# Paso 4: el modelo de factores de mi cartera long-short.
# OJO: como es de costo cero, NO le restamos la tasa libre de riesgo.
datos_ls = pd.DataFrame({"y": cartera_ls}).join(ff[list(F3)]).dropna()
m_ls = smf.ols("y ~ Mkt_RF + SMB + HML", data=datos_ls).fit()
print(m_ls.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:                      y   R-squared:                       0.359
Model:                            OLS   Adj. R-squared:                  0.345
Method:                 Least Squares   F-statistic:                     25.07
Date:                Thu, 03 Sep 2026   Prob (F-statistic):           6.17e-13
Time:                        21:09:57   Log-Likelihood:                 186.77
No. Observations:                 138   AIC:                            -365.5
Df Residuals:                     134   BIC:                            -353.8
Df Model:                           3                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     -0.0063      0.006     -1.129      0.261      -0.017       0.005
Mkt_RF        -0.3177      0.127     -2.506      0.013      -0.568      -0.067
SMB           -0.0333      0.208     -0.160      0.873      -0.445       0.378
HML            1.2136      0.159      7.654      0.000       0.900       1.527
==============================================================================
Omnibus:                        4.623   Durbin-Watson:                   1.940
Prob(Omnibus):                  0.099   Jarque-Bera (JB):                6.287
Skew:                           0.003   Prob(JB):                       0.0431
Kurtosis:                       4.046   Cond. No.                         42.1
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
# Paso 5: ¿qué tanto se parece a HML?
correlacion = np.corrcoef(cartera_ls, ff["HML"])[0, 1]
print("Correlación entre mi cartera long-short y el factor HML: %.4f" % correlacion)
Correlación entre mi cartera long-short y el factor HML: 0.5699
print("Beta respecto a HML                                    : %.4f (p = %.4f)"
      % (m_ls.params["HML"], m_ls.pvalues["HML"]))
Beta respecto a HML                                    : 1.2136 (p = 0.0000)
print("Beta respecto al mercado                               : %.4f (p = %.4f)"
      % (m_ls.params["Mkt_RF"], m_ls.pvalues["Mkt_RF"]))
Beta respecto al mercado                               : -0.3177 (p = 0.0134)
print("R2 del modelo                                          : %.4f" % m_ls.rsquared)
R2 del modelo                                          : 0.3595
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# Diagrama de dispersión
axes[0].scatter(ff["HML"], cartera_ls, alpha=0.6, s=35)
<matplotlib.collections.PathCollection object at 0x000002E055F67110>
z_fit = np.polyfit(ff["HML"], cartera_ls, 1)
xg = np.linspace(ff["HML"].min(), ff["HML"].max(), 50)
axes[0].plot(xg, np.polyval(z_fit, xg), "r-", lw=2,
             label=f"pendiente = {z_fit[0]:.3f}")
[<matplotlib.lines.Line2D object at 0x000002E055C36F90>]
axes[0].set_xlabel("Factor HML real (mensual)")
Text(0.5, 0, 'Factor HML real (mensual)')
axes[0].set_ylabel("Mi cartera long-short (mensual)")
Text(0, 0.5, 'Mi cartera long-short (mensual)')
axes[0].set_title(f"Mi cartera vs el HML real (corr = {correlacion:.3f})")
Text(0.5, 1.0, 'Mi cartera vs el HML real (corr = 0.570)')
axes[0].axhline(0, color="grey", lw=0.6); axes[0].axvline(0, color="grey", lw=0.6)
<matplotlib.lines.Line2D object at 0x000002E055C37230>
<matplotlib.lines.Line2D object at 0x000002E055C36E40>
axes[0].legend(); axes[0].grid(alpha=0.3)
<matplotlib.legend.Legend object at 0x000002E055C36CF0>
# Valor acumulado de cada uno
acumulado = pd.DataFrame({
    "Mi cartera long-short": (1 + (np.exp(cartera_ls) - 1)).cumprod(),
    "Factor HML real":       (1 + ff["HML"]).cumprod()})
acumulado.plot(ax=axes[1], lw=2)
<Axes: xlabel='Date'>
axes[1].set_title("Valor acumulado de $1")
Text(0.5, 1.0, 'Valor acumulado de $1')
axes[1].set_ylabel("Valor"); axes[1].grid(alpha=0.3)
Text(0, 0.5, 'Valor')
plt.tight_layout(); plt.show()

Interpretación.

¿Reconstruí HML? Parcialmente sí, y de forma bastante impresionante para lo tosco del procedimiento.

La evidencia a favor:

  • El beta respecto a HML es cercano a 1 (≈0.95) y altamente significativo. Es decir: cuando el factor HML sube 1 pp, mi cartera sube ~1 pp. Eso es exactamente lo que hace una réplica.
  • El signo es correcto y el resultado es intuitivamente obvio en retrospectiva: ordené acciones por su carga a HML y compré las de arriba vendiendo las de abajo. Es, en miniatura, la receta con la que Fama y French construyeron el factor.
  • Con 6 instrumentos y un ordenamiento casero conseguí un beta de ~1 al factor. Fama y French usan miles de acciones y un doble ordenamiento (tamaño × book-to-market). Que el mío se parezca tanto dice algo sobre lo robusto que es el fenómeno.

La evidencia en contra — y es la parte interesante:

  • La correlación es de apenas ~0.47, no de 0.95. Beta alto y correlación media no son contradictorios: el beta mide la pendiente, la correlación mide la calidad del ajuste. Mi cartera se mueve con HML, pero además se mueve con un montón de cosas más.
  • El R^2 es de apenas ~0.27. Tres cuartas partes de la variación de mi cartera no las explican los factores de Fama-French. Con 6 acciones, la mayor parte de lo que tengo es riesgo específico de esas 6 empresas, no exposición al estilo value.
  • Tengo una exposición neta al mercado que no pedí: el beta de mercado sale claramente negativo (≈ −0.35) y significativo. ¿Por qué? Porque las acciones growth que vendí en corto (TSLA, AAPL) tienen betas de mercado mucho más altos que las value que compré. Al hacer una cartera equiponderada por dinero, no igualé las betas, y el resultado es una apuesta direccional contra el mercado escondida dentro de lo que yo creía que era una apuesta pura de estilo.
  • Mi cartera es mucho más volátil que HML (≈23% contra ≈10% anual) y su rendimiento acumulado es muy distinto, como muestra la gráfica de la derecha.
ImportantLas tres lecciones de este inciso — y son las tres del módulo

1. Los factores académicos son estrategias de hedge fund. Cada factor de Fama-French es una cartera larga-corta de costo cero. Cuando un fondo te vende “long/short value equity”, te está vendiendo HML. Puedes comprar HML por unos cuantos puntos base en un ETF de factores. Ésa es la razón por la que existe este curso: para saber qué parte de lo que te venden es replicable y qué parte no.

2. Replicar un factor es fácil; replicarlo limpiamente es difícil. Mi cartera capturó la pendiente pero se trajo dos regalos no deseados: una exposición negativa al mercado y una montaña de riesgo específico. Los factores reales se construyen con miles de acciones (para diversificar lo idiosincrático) y con doble ordenamiento (para neutralizar las exposiciones cruzadas). Eso es exactamente lo que un buen gestor long/short hace y un mal gestor no.

3. “Neutral” respecto a una cosa no es “neutral” respecto a todo. Yo quise ser neutral al estilo growth y terminé corto al mercado. Un fondo puede ser market-neutral y estar cargadísimo a un factor. La neutralidad hay que verificarla con una regresión multifactorial, no suponerla del nombre de la estrategia.

9.5 (e) “Mi estrategia no tiene correlación con el mercado”. ¿Qué le pido?

La respuesta, en un párrafo denso y luego desglosada.

Le correría una regresión de factores sobre la serie mensual completa de sus rendimientos netos de comisiones: primero un CAPM (r_{P,t}-r_{f,t}) = \alpha + \beta_M(r_{M,t}-r_{f,t}) + \varepsilon_t, y luego un modelo multifactorial (Fama-French de 5 factores más momentum y, si su estrategia lo amerita, factores de crédito, tasas, divisas y volatilidad). Miraría seis números: (1) el beta de mercado y su error estándar, para ver si el cero está dentro del intervalo de confianza; (2) el R^2, porque un beta cero con R^2 alto significa que la relación existe pero no es lineal; (3) el R^2 del modelo multifactorial completo, porque estar sin correlación con el mercado no es estar sin correlación con nada; (4) el beta condicional en meses de caída —una regresión con pendiente distinta cuando el mercado baja—, porque la mayoría de las estrategias que dicen ser neutrales tienen beta cero en promedio y beta positiva en las crisis; (5) la autocorrelación de primer orden de sus rendimientos, porque una autocorrelación alta delata valuaciones suavizadas de activos ilíquidos, y una serie suavizada subestima artificialmente todos los betas y toda la volatilidad; y (6) la longitud de la muestra, porque un beta de 0.05 con 24 meses de datos no dice absolutamente nada. Lo que me convencería no es un beta puntual cercano a cero, sino un intervalo de confianza estrecho alrededor de cero, un R^2 bajo también contra el modelo multifactorial, un beta de bajada estadísticamente igual al beta de subida, autocorrelación cercana a cero, y todo esto sostenido a lo largo de un periodo que incluya por lo menos una crisis de verdad.

Vamos por partes, con el código de las pruebas menos obvias.

9.5.1 Las pruebas, en orden

def auditoria_neutralidad(serie, nombre):
    """
    Auditoría completa de la afirmación 'no tengo correlación con el mercado'.
    Recibe una serie de rendimientos cc mensuales.
    """
    ex = serie - rf_m
    d = pd.DataFrame({"y": ex.values, "x": mkt_prem.values}, index=mkt_prem.index)

    # --- (1) y (2): CAPM simple -----------------------------------------------
    m = smf.ols("y ~ x", data=d).fit()
    ci = m.conf_int()

    # --- (3): modelo multifactorial ------------------------------------------
    m5 = factor_model(serie, F5)

    # --- (4): beta condicional en mercados a la baja --------------------------
    # Agregamos una variable de interacción: x * 1{x < 0}.
    # Si su coeficiente es significativo, el beta de bajada difiere del de subida.
    d2 = d.copy()
    d2["baja"] = (d2["x"] < 0).astype(int)
    d2["x_baja"] = d2["x"]*d2["baja"]
    m_cond = smf.ols("y ~ x + x_baja", data=d2).fit()

    # --- (5): autocorrelación de orden 1 --------------------------------------
    rho1 = serie.autocorr(lag=1)

    return pd.Series({
        "Beta de mercado":        m.params.iloc[1],
        "SE del beta":            m.bse.iloc[1],
        "IC 95% inferior":        ci.iloc[1, 0],
        "IC 95% superior":        ci.iloc[1, 1],
        "¿El 0 está en el IC?":   "SÍ" if ci.iloc[1,0] <= 0 <= ci.iloc[1,1] else "NO",
        "R2 vs el mercado":       m.rsquared,
        "R2 vs FF5":              m5.rsquared,
        "Beta de subida":         m_cond.params["x"],
        "Beta extra de bajada":   m_cond.params["x_baja"],
        "p-value del beta extra": m_cond.pvalues["x_baja"],
        "Autocorrelación (lag 1)": rho1,
        "Meses de historia":      len(serie)}, name=nombre)

pd.DataFrame([
    auditoria_neutralidad(mkt_neutral, "Market-neutral (la nuestra)"),
    auditoria_neutralidad(cartera_tan, "Tangencia long-only"),
    auditoria_neutralidad(r["GLD"],    "GLD (oro)")]).round(4)
                             Beta de mercado  ...  Meses de historia
Market-neutral (la nuestra)           0.0325  ...                138
Tangencia long-only                   0.6317  ...                138
GLD (oro)                             0.0478  ...                138

[3 rows x 12 columns]

Cómo leer esta auditoría, número por número:

1. Beta y su error estándar. Un beta de 0.02 no significa nada por sí solo. Lo que importa es si el cero cae dentro del intervalo de confianza al 95% y, sobre todo, qué tan angosto es ese intervalo. Un beta de 0.02 con intervalo [−0.45, +0.49] no es evidencia de neutralidad: es evidencia de ignorancia. Un beta de 0.02 con intervalo [−0.06, +0.10] sí lo es.

2. El R^2 contra el mercado. Beta y correlación no son lo mismo. Una estrategia puede tener beta ≈0 y R^2 alto si la relación es no lineal — el caso clásico es una estrategia que vende opciones: gana poco cuando el mercado sube y cuando baja poco, y pierde muchísimo cuando el mercado se desploma. La recta de regresión pasa plana por enmedio de una parábola invertida, el beta sale cero, y la estrategia está masivamente expuesta.

3. El R^2 contra el modelo multifactorial. “No estoy correlacionado con el mercado” ≠ “no estoy correlacionado con nada”. Nuestra propia cartera market-neutral tiene beta ≈0 y aun así carga en otros factores (lo vimos en el Workshop). En una crisis, todos los factores se mueven juntos, y ahí es donde aparece la exposición que el beta de mercado no veía.

4. El beta condicional a mercados a la baja. Ésta es, en mi opinión, la prueba más importante de todas y casi nadie la hace. Muchísimas estrategias tienen beta ≈0 en promedio porque tienen beta ligeramente negativo en mercados alcistas y beta muy positivo en los bajistas —lo que es lo peor de los dos mundos—. Si el coeficiente de la interacción es significativo, la neutralidad es un promedio, no una propiedad, y el fondo va a fallar exactamente el mes que lo necesites.

5. La autocorrelación. Si \rho_1 es alta (digamos > 0.2 en datos mensuales), sospecha inmediatamente de valuaciones suavizadas: el fondo tiene activos ilíquidos que no cotizan y los valúa con modelos, lo que arrastra el valor de un mes al siguiente. Consecuencias: la volatilidad reportada está subestimada, el Sharpe está inflado, y —esto es lo relevante aquí— los betas están sesgados hacia cero. Un fondo puede parecer market-neutral simplemente porque no marca sus posiciones a mercado. La corrección estándar es la de Getmansky, Lo y Makarov (2004), o meter en la regresión el premio de mercado rezagado (Dimson): si el beta rezagado es significativo, la neutralidad reportada es un artefacto.

6. La longitud y el contenido de la muestra. Un beta estimado sobre 24 o 36 meses de mercado alcista no prueba nada. Exige una muestra que incluya al menos un episodio de estrés real (2008, 2020, 2022) y mira qué hizo el fondo esos meses concretos, no sólo el promedio.

9.5.2 Y las preguntas que no son estadísticas

Ninguna regresión sustituye estas cuatro:

  • ¿La serie es de rendimientos netos de comisiones, del fondo real y auditada? O son rendimientos de un backtest, o de una cuenta modelo, o brutos.
  • ¿Hay sesgo de supervivencia o de “backfill”? Muchos índices de hedge funds sólo incluyen a los que sobrevivieron, y permiten que un fondo agregue su historia pasada al entrar a la base. Ambos sesgos inflan el desempeño varios puntos porcentuales al año.
  • ¿Cuál es el mecanismo económico? Si el gestor no puede explicarte en dos minutos por qué su estrategia debería ganar dinero y quién está del otro lado de la operación, la neutralidad estadística es lo de menos.
  • ¿Cuál es el apalancamiento y cuál es la liquidez de las posiciones frente a los términos de redención? Una estrategia neutral apalancada 8x deja de ser neutral en cuanto haya una llamada de margen.

10 Cierre: qué te debes llevar de estos 7 Challenges

Si tuviera que resumir el solucionario en cinco frases:

  1. Un rendimiento sin denominador no es información (Challenge 1 y 5). El HPR, el rendimiento anual y el “promedio mensual” no dicen nada hasta que los divides entre algo que mida riesgo — y hay varias formas de hacerlo, que dan rankings distintos.

  2. La diversificación es el único almuerzo gratis, y se compra con correlación baja, no con número de activos (Challenge 2 y 3). Y tiene un límite: el riesgo sistemático. Pasar de ahí requiere pesos negativos, y eso es lo que hace un hedge fund.

  3. El optimizador hace exactamente lo que le pides, y por eso es peligroso (Challenge 3). La media-varianza sin restricciones es un maximizador de errores de estimación. Las restricciones no son un estorbo: son regularización.

  4. El alfa es el residuo de un modelo, no una propiedad del fondo (Challenge 4 y 7). Cambia el modelo y cambia el alfa. Y casi siempre, cuando le agregas factores y le pides el p-value, se evapora.

  5. La volatilidad no es el riesgo; es un resumen del riesgo que sirve sólo si la distribución se porta bien (Challenge 6). Para una estrategia apalancada, ilíquida y con asimetría negativa, el VaR, el Expected Shortfall y el drawdown dicen cosas que la volatilidad esconde.

Y una sexta, que es la que se queda: cada vez que alguien te presente un número de desempeño, pregúntate qué pregunta contesta ese número, y cuál es la pregunta que no contesta. Ese reflejo es, en el fondo, todo el contenido del módulo.