Workshop 2 - Hedge Funds — Solución Parte II: Neutralidad, futuros y portable alpha (Challenge 2)

M6 – Hedge Funds | Solucionario de la Parte II

Author

Alberto Dorantes, Ph.D.

Published

October 12, 2026

Abstract
Solución completa y comentada de el Challenge 2 (incisos a, b y c) de la Parte II del Workshop 2. Cada respuesta incluye la idea de lo que se pregunta, el código de Python comentado y la interpretación del output, que es la parte que realmente se evalúa. Los términos técnicos se dejan en inglés. El documento es autocontenido: se puede correr sin haber ejecutado antes el Workshop.

1 Cómo usar este documento

Este es el solucionario de la Parte II del Workshop 2. Léelo después de haber intentado los ejercicios por tu cuenta.

ImportantLos números que verás dependen de la muestra

Este documento usa los mismos archivos CSV del Workshop (datos reales congelados a agosto de 2026, USE_LOCAL_DATA = True). Si actualizas los datos con build_workshop2_data.py, los números cambiarán un poco. Lo que no debe cambiar es la interpretación: el signo de un coeficiente, el orden de magnitud y la conclusión económica.

ImportantRendimientos simples vs rendimientos cc (log): la convención a partir de ahora

En el Workshop 1 estimamos el CAPM (y los modelos de factores) con rendimientos cc (logarítmicos), y en el primer ejemplo de cobertura construimos la pata larga como un promedio ponderado de rendimientos cc. A partir de este Workshop usamos rendimientos simples para esos cálculos, y así están resueltas todas las soluciones de este documento:

  1. El rendimiento de una cartera es el promedio ponderado de los rendimientos simples, R_P=\sum_i w_iR_i. Con logaritmos no se cumple: r_P=\ln\left(\sum_i w_ie^{r_i}\right)\neq\sum_i w_ir_i. Toda cartera, cobertura o pata long/short se construye con rendimientos simples.
  2. El CAPM es un modelo de rendimientos simples. Con logaritmos la beta casi no cambia, pero la alfa queda sesgada hacia abajo en aproximadamente la mitad de la varianza residual: \alpha_{log}\approx\alpha-\tfrac{1}{2}\sigma^2_e+\tfrac{1}{2}\beta(1-\beta)\sigma^2_M. Con una volatilidad residual de 40% anual, son unos -8\% anuales de alfa “falsa”. En hedge funds, la alfa es justamente lo que evaluamos.
  3. El Sharpe ratio se define con la media aritmética de los excesos de rendimiento simples; con logaritmos sale menor en aproximadamente \tfrac{1}{2}\sigma (volatilidad anual).

Los rendimientos cc se siguen usando para agregar en el tiempo (valor acumulado y rendimiento anual compuesto e^{12\bar r}-1). La regla: entre activos y para medidas de desempeño, simples; a través del tiempo, cc. Para anualizar: medias y alfas \times12, volatilidades \times\sqrt{12}; el rendimiento anual realizado es el compuesto, \left[\prod_t(1+R_t)\right]^{12/T}-1.

Para el Workshop 1: las betas y los hedge ratios que estimaste son prácticamente iguales con cualquiera de los dos rendimientos; las alfas quedaron algo subestimadas, sobre todo en acciones volátiles. No es necesario rehacerlo.

2 Setup: librerías, datos y objetos base

Esta sección replica el setup del Workshop (y las funciones que se fueron construyendo en cada Parte) para que este documento corra solo.

2.1 Librerías y datos

import numpy as np                       # álgebra matricial
import pandas as pd                      # tablas y series de tiempo
import matplotlib.pyplot as plt          # gráficas
import statsmodels.api as sm             # regresiones (OLS con matrices)
import statsmodels.formula.api as smf    # regresiones con fórmulas y ~ x
from scipy import optimize, stats        # optimización y distribuciones

pd.set_option("display.float_format", lambda x: f"{x:,.4f}")
pd.set_option("display.width", 120)
pd.set_option("display.max_columns", 20)

USE_LOCAL_DATA = True
if USE_LOCAL_DATA:
    prices = pd.read_csv("hf2_prices_monthly.csv", index_col="Date", parse_dates=True)
    ff     = pd.read_csv("hf2_factors_monthly.csv", index_col="Date", parse_dates=True)
    rates  = pd.read_csv("hf2_rates_monthly.csv", index_col="Date", parse_dates=True)
else:
    import build_workshop2_data as bd
    prices, ff, rates = bd.get_prices(), bd.get_factors(), bd.get_rates()

# Rendimientos simples (Rs): carteras, coberturas, regresiones CAPM/factores,
#   Sharpe, VaR/ES. Rendimientos cc (r): sólo para agregar en el tiempo.
r  = np.log(prices.drop(columns="VIX")).diff().dropna()
Rs = prices.drop(columns="VIX").pct_change().dropna()
r, ff = r.align(ff, join="inner", axis=0)      # meses comunes con los factores
Rs = Rs.loc[r.index]

rf_s = ff["RF"]                  # tasa libre de riesgo mensual (simple), EE.UU.
rates_m = rates.loc[r.index]     # tasas ANUALES de EE.UU. y México
i_mx = rates_m["MX_3M"].shift(1).bfill()    # tasa conocida al inicio de cada mes
i_us = rates_m["US_TB3M"].shift(1).bfill()
mkt_ex = Rs["SPY"] - rf_s                   # prima de mercado (simple)

HF = ["QAI", "HDG", "MNA", "MERFX", "BTAL", "QMNIX", "AQMIX", "QSPIX"]
STYLES = ["SPY", "IWM", "EFA", "EEM", "IEF", "TLT", "HYG", "GLD", "UUP", "DBC"]
FF6 = ("Mkt_RF", "SMB", "HML", "RMW", "CMA", "MOM")
ann_ret = lambda x: (1 + x).prod()**(12/len(x)) - 1     # rendimiento anual (simple)
print(r.index.min().date(), "->", r.index.max().date(), "|", len(r), "meses")
2015-01-31 -> 2026-07-31 | 139 meses

2.2 Funciones del Workshop

def max_drawdown(ret):
    """Drawdown máximo de una serie mensual de rendimientos SIMPLES."""
    wealth = (1 + ret).cumprod()
    return (wealth / wealth.cummax() - 1).min()

def hf_stats(ret, rf=rf_s, mkt=Rs["SPY"]):
    """Estadísticas de una serie mensual de rendimientos SIMPLES."""
    rf, mkt = rf.loc[ret.index], mkt.loc[ret.index]
    ex = ret - rf                                     # prima simple
    return pd.Series({
        "Ann. return": ann_ret(ret),                  # rendimiento anual compuesto
        "Ann. vol":    ret.std() * np.sqrt(12),
        "Sharpe":      ex.mean() / ex.std() * np.sqrt(12),
        "Beta":        np.cov(ret, mkt)[0, 1] / mkt.var(),
        "Corr":        ret.corr(mkt),
        "Skew":        stats.skew(ret),
        "Ex. kurt":    stats.kurtosis(ret),
        "AC(1)":       ret.autocorr(1),
        "Max DD":      max_drawdown(ret)})

# --- Style analysis (Parte I) ---------------------------------------------
X_style = Rs[STYLES].copy()
X_style["CASH"] = rf_s

def style_analysis(fund, X=X_style):
    y, Xv = fund.values * 100, X.values * 100
    K = Xv.shape[1]
    te = lambda w: np.var(y - Xv @ w)
    res = optimize.minimize(te, np.repeat(1/K, K), method="SLSQP", bounds=[(0, 1)] * K,
                            constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}],
                            options={"ftol": 1e-12, "maxiter": 1000})
    w = pd.Series(res.x, index=X.columns)
    e = fund - X @ w
    return w, 1 - e.var() / fund.var(), e

def hf_style(fund, styles=STYLES, rets=Rs, rf=rf_s):
    d = rets[[fund] + styles].sub(rf, axis=0)
    return sm.OLS(d[fund], sm.add_constant(d[styles])).fit()

# --- Futuros del IPC y coberturas (Parte II) ---------------------------------
q_ipc = 0.025
fut_ipc = Rs["IPC"] - (i_mx - q_ipc) / 12

def hedge_report(df, market=fut_ipc):
    out = {}
    for c in df:
        x = df[c]
        out[c] = {"Annual return": ann_ret(x), "Annual vol": x.std() * np.sqrt(12),
                  "Beta (IPC)": np.cov(x, market)[0, 1] / market.var(),
                  "Corr (IPC)": x.corr(market)}
    return pd.DataFrame(out).T

def pa_report(pa, bench):
    act = pa - bench
    return pd.DataFrame({
        "Annual return":  [ann_ret(bench), ann_ret(pa)],
        "Annual vol":     [bench.std()*np.sqrt(12), pa.std()*np.sqrt(12)],
        "Tracking error": [0, act.std()*np.sqrt(12)],
        "Info ratio":     [np.nan, act.mean()/act.std()*np.sqrt(12)]},
        index=["Benchmark", "Portable alpha"])

# --- Carry trade (Parte III) ------------------------------------------------
S = prices["USDMXN"]
carry = pd.DataFrame(index=r.index)
carry["Interest differential"] = (i_mx - i_us) / 12
carry["Currency return"] = (S.shift(1) / S).loc[r.index] - 1
carry["Carry trade"] = (1 + i_mx/12) * (S.shift(1) / S).loc[r.index] - (1 + i_us/12)

# --- Treynor-Black y Black-Litterman (Parte V) -------------------------------
MKT_PREMIUM = 0.055
sigma_M = mkt_ex.std() * np.sqrt(12)

def index_model(f):
    m = sm.OLS(Rs[f] - rf_s, sm.add_constant(mkt_ex)).fit()
    return pd.Series({"alpha": m.params.iloc[0]*12, "alpha SE": m.bse.iloc[0]*12,
                      "beta": m.params.iloc[1], "resid var": m.resid.var()*12})

def treynor_black(alpha, beta, resid_var, mkt_prem=MKT_PREMIUM, sig_m=sigma_M):
    w = (alpha / resid_var) / (alpha / resid_var).sum()
    a_A, b_A, s2e_A = w @ alpha, w @ beta, (w**2) @ resid_var
    wA0 = (a_A / s2e_A) / (mkt_prem / sig_m**2)
    wA = wA0 / (1 + (1 - b_A) * wA0)
    S_M, IR_A = mkt_prem / sig_m, a_A / np.sqrt(s2e_A)
    return w, pd.Series({"alpha_A": a_A, "beta_A": b_A, "sigma(e_A)": np.sqrt(s2e_A),
                         "IR_A": IR_A, "w_A (initial)": wA0, "w_A* (adjusted)": wA,
                         "Sharpe market": S_M, "Sharpe optimal": np.sqrt(S_M**2 + IR_A**2)})

fx = Rs["USDMXN"]
to_mxn = lambda x: (1 + x) * (1 + fx) - 1
hf_sleeve = Rs[["QMNIX", "QSPIX", "AQMIX", "MERFX"]].mean(axis=1)
fo = pd.DataFrame({"MX_EQ": to_mxn(Rs["EWW"]), "US_EQ": to_mxn(Rs["SPY"]),
                   "INTL_EQ": to_mxn(Rs["EFA"]), "UST": to_mxn(Rs["IEF"]),
                   "GOLD": to_mxn(Rs["GLD"]), "HF": to_mxn(hf_sleeve)})
fo_ex = fo.sub(i_mx / 12, axis=0)
ASSETS_FO = list(fo.columns)
Sigma = fo_ex.cov() * 12
w_eq = pd.Series({"MX_EQ": 0.25, "US_EQ": 0.30, "INTL_EQ": 0.10,
                  "UST": 0.20, "GOLD": 0.05, "HF": 0.10})
DELTA = 0.04 / (w_eq @ Sigma @ w_eq)
Pi = DELTA * Sigma @ w_eq
TAU = 0.05

def black_litterman(Sigma, Pi, P, Q, tau=TAU, conf=1.0):
    Sm, Pm = Sigma.values, P.values
    Omega = np.diag(np.diag(Pm @ (tau * Sm) @ Pm.T)) / conf
    A = np.linalg.inv(tau * Sm) + Pm.T @ np.linalg.inv(Omega) @ Pm
    b = np.linalg.inv(tau * Sm) @ Pi.values + Pm.T @ np.linalg.inv(Omega) @ Q.values
    mu = np.linalg.solve(A, b)
    return (pd.Series(mu, index=Sigma.index),
            pd.DataFrame(Sm + np.linalg.inv(A), index=Sigma.index, columns=Sigma.index))

def constrained_mv(mu, S, delta=DELTA, caps={"HF": 0.15}):
    n = len(mu)
    obj = lambda w: -(w @ mu.values - 0.5 * delta * w @ S.values @ w)
    res = optimize.minimize(obj, np.repeat(1/n, n), method="SLSQP",
                            bounds=[(0, caps.get(a, 1)) for a in mu.index],
                            constraints=[{"type": "ineq", "fun": lambda w: 1 - w.sum()}])
    return pd.Series(res.x, index=mu.index)

3 PARTE II — Solución: neutralidad, futuros y portable alpha (Challenge 2)

3.1 (a) Las tres neutralidades con América Móvil (AMXB)

stock_amx = Rs["AMXB"]
beta_amx = np.cov(stock_amx, fut_ipc)[0, 1] / fut_ipc.var()
print(f"Beta de AMXB respecto al futuro del IPC: {beta_amx:.3f}")
Beta de AMXB respecto al futuro del IPC: 0.765
hedges_amx = pd.DataFrame({
    "Sin cobertura (largo AMXB)": stock_amx,
    "Dollar-neutral":             stock_amx - 1.0 * fut_ipc,
    "Beta-neutral":               stock_amx - beta_amx * fut_ipc})
hedge_report(hedges_amx).round(4)
                            Annual return  Annual vol  Beta (IPC)  Corr (IPC)
Sin cobertura (largo AMXB)         0.0580      0.2244      0.7653      0.5169
Dollar-neutral                     0.0634      0.1954     -0.2347     -0.1820
Beta-neutral                       0.0643      0.1921     -0.0000     -0.0000

Interpretación. América Móvil tiene una beta de \approx 0.77, menor que uno: es una acción defensiva (telecomunicaciones, ingresos estables). La posición dollar-neutral vende un peso de futuros por cada peso de acción, es decir, más cobertura de la necesaria: queda sobre-cubierta (over-hedged), con beta de \approx -0.23 y correlación negativa con el IPC. En Grupo México pasaba lo contrario (beta 1.31, posición dollar-neutral sub-cubierta). La regla: dollar-neutral sólo coincide con beta-neutral cuando \beta=1.

3.1.1 ¿Es factor-neutral la posición beta-neutral en AMXB?

Xf_amx = pd.DataFrame({"IPC_fut": fut_ipc, "USDMXN": Rs["USDMXN"],
                       "DBC": Rs["DBC"], "SPY": Rs["SPY"]})
m_amx = sm.OLS(hedges_amx["Beta-neutral"], sm.add_constant(Xf_amx)).fit()
print(m_amx.summary().tables[1])
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
const          0.0079      0.005      1.581      0.116      -0.002       0.018
IPC_fut       -0.1002      0.144     -0.694      0.489      -0.386       0.185
USDMXN        -0.3008      0.164     -1.835      0.069      -0.625       0.023
DBC           -0.0576      0.103     -0.559      0.577      -0.261       0.146
SPY           -0.0273      0.138     -0.198      0.844      -0.300       0.245
==============================================================================
print(f"R2 = {m_amx.rsquared:.3f}")
R2 = 0.025

Interpretación. A diferencia de Grupo México (cuyo R^2 era \approx 0.5, dominado por cobre y tipo de cambio), la posición beta-neutral en América Móvil es casi factor-neutral respecto a estos factores: R^2\approx0.03, ningún coeficiente significativo al 5%. La única carga cercana a la significancia es USDMXN (\approx-0.30, t\approx-1.8), con signo negativo: cuando el peso se deprecia, América Móvil lo hace peor que el IPC: sus ingresos están en pesos y en otras monedas latinoamericanas, mientras que parte de su deuda y de sus costos (equipo, espectro) están en moneda extranjera. Con 139 meses, sin embargo, no podemos afirmarlo con 95% de confianza.

La explicación económica de la diferencia: Grupo México es un exportador de commodities con ingresos en dólares, así que su riesgo específico está dominado por factores globales (cobre, tipo de cambio). América Móvil es una empresa de servicios domésticos (regulados, de demanda estable), cuyo riesgo específico es mucho más idiosincrático. Cubrir el mercado en AMXB deja una apuesta casi pura sobre la empresa; en GMEXICO deja una apuesta sobre el cobre.

3.2 (b) Número de contratos de futuros del IPC

La idea. F_0=S_0\left(1+(i-q)T\right) y N^*=\beta\,V/(F\cdot m), con m=10 pesos por punto. Para cambiar la beta de \beta_P a \beta^*, el número de contratos es (\beta^*-\beta_P)\,V/(F\cdot m): positivo = comprar futuros, negativo = vender.

V, beta_p, S0_ipc, i, q, T, m = 120e6, 0.85, 65_000, 0.07, 0.025, 3/12, 10
F0 = S0_ipc * (1 + (i - q) * T)
N_neutral = (0 - beta_p) * V / (F0 * m)
N_up = (1.2 - beta_p) * V / (F0 * m)
print(f"(i)   Futuro teórico a 3 meses : {F0:,.2f} puntos")
(i)   Futuro teórico a 3 meses : 65,731.25 puntos
print(f"(ii)  Para beta = 0   : {N_neutral:,.1f} contratos -> VENDER {abs(round(N_neutral))}")
(ii)  Para beta = 0   : -155.2 contratos -> VENDER 155
print(f"(iii) Para beta = 1.2 : {N_up:,.1f} contratos -> COMPRAR {round(N_up)}")
(iii) Para beta = 1.2 : 63.9 contratos -> COMPRAR 64

Interpretación. El futuro cotiza por arriba del spot (65,731 vs 65,000) porque la tasa en pesos (7%) es mayor que el dividend yield (2.5%): mantener el índice cuesta más de lo que paga. Para neutralizar la cartera hay que vender \approx 155 contratos; para subir la beta a 1.2 hay que comprar \approx 64 contratos. Con futuros, un fondo puede mover su exposición al mercado en minutos, sin tocar sus acciones: ésa es la herramienta básica de los fondos long/short y de portable alpha.

3.3 (c) Portable alpha con MERFX como fuente de alpha

pa_merfx = Rs["MERFX"] + (Rs["SPY"] - rf_s)
pa_qmnix = Rs["QMNIX"] + (Rs["SPY"] - rf_s)
print("MERFX como fuente de alpha:"); print(pa_report(pa_merfx, Rs["SPY"]).round(4))
MERFX como fuente de alpha:
                Annual return  Annual vol  Tracking error  Info ratio
Benchmark              0.1364      0.1502          0.0000         NaN
Portable alpha         0.1506      0.1632          0.0277      0.5266
print("\nQMNIX como fuente de alpha:"); print(pa_report(pa_qmnix, Rs["SPY"]).round(4))

QMNIX como fuente de alpha:
                Annual return  Annual vol  Tracking error  Info ratio
Benchmark              0.1364      0.1502          0.0000         NaN
Portable alpha         0.1935      0.1639          0.0968      0.5352
roll12 = lambda x: (1 + x).rolling(12).apply(np.prod, raw=True) - 1
roll = pd.DataFrame({"MERFX + futuros S&P": roll12(pa_merfx - Rs["SPY"]),
                     "QMNIX + futuros S&P": roll12(pa_qmnix - Rs["SPY"])})
roll.plot(figsize=(12, 4), title="Rendimiento activo a 12 meses sobre el S&P 500")
plt.axhline(0, color="black", lw=0.8)
plt.grid(alpha=0.3)
plt.show()

print(pd.DataFrame({f: hf_stats(Rs[f])[["Ann. vol", "Skew", "Max DD"]]
                    for f in ["MERFX", "QMNIX"]}).T.round(3))
       Ann. vol    Skew  Max DD
MERFX    0.0280 -0.6740 -0.0410
QMNIX    0.0970  0.5070 -0.3840

Interpretación. Con MERFX el portable alpha gana \approx 1.4 puntos porcentuales al año sobre el S&P 500 (15.1% vs 13.6%), mucho menos que con QMNIX (\approx 5.7 puntos), pero con un tracking error de sólo 2.8% (vs \approx 9.7%). El Information ratio es prácticamente el mismo (\approx 0.53). La gráfica cuenta la diferencia: con MERFX el peor año rodante quedó sólo \approx 3.5% por debajo del benchmark; con QMNIX hubo tres años seguidos perdiendo entre 13% y 20%.

¿Cuál prefiero? Para un comité de inversión, MERFX: el mismo IR con una fracción del riesgo relativo, y un max drawdown de 4% contra 38%. Un programa de portable alpha sólo sobrevive si el comité lo aguanta en los años malos. La advertencia: MERFX tiene sesgo negativo (-0.7): su riesgo está en la cola (una ola de rupturas de fusiones en una crisis de crédito), y el tracking error bajo no lo captura. La solución práctica es combinar varias fuentes de alpha poco correlacionadas.

¿Qué Information ratio es el de pa_report? Es la versión respecto a un benchmark, \overline{R_{PA}-R_B}/\sigma(R_{PA}-R_B), y aquí es la correcta: la estrategia de portable alpha sustituye al S&P 500 y se evalúa contra él. Además, como R_{PA}-R_B=R_{fuente}-R_f, este IR es exactamente el Sharpe ratio de la fuente de alpha (por eso sale \approx0.53 para ambas: MERFX y QMNIX tienen Sharpe de \approx0.53). Para evaluar la fuente de alpha como fondo que se agrega a una cartera usaríamos su appraisal ratio, \alpha/\sigma(e).