Métricas de rendimiento como funciones reutilizables: CAGR, drawdown, Sharpe, Sortino, factor de beneficio y sus pruebas unitarias
Escribe un pequeño módulo de métricas con una anualización correcta, añade versiones móviles y demuestra las funciones con pytest frente a valores calculados a mano.
Qué aprenderás
- Cómo escribir cada métrica como una función pura que recibe un objeto de pandas y devuelve un único número.
- Dónde interviene la anualización: la regla de la raíz cuadrada del tiempo para la volatilidad y la constante de periodos por año.
- Cómo revelan las versiones móviles cuándo funcionó una estrategia y cuándo no.
- Cómo someter una métrica a una prueba unitaria frente a un valor calculado a mano para que una refactorización posterior no pueda romperla en silencio.
Explicación
Las definiciones de la CAGR, el drawdown máximo, el ratio de Sharpe y el factor de beneficio se trataron en Trading sistemático y backtesting (lección 5) y, a partir de un diario de trading, en Gestión del riesgo y de las operaciones (lección 9). Esta lección trata de la implementación: reunirlas en un solo archivo, metrics.py, para que cada notebook importe el mismo código probado en lugar de volver a teclear las fórmulas.
Reglas de diseño del módulo
- Cada función recibe o bien una serie de capital (euros o nivel de índice,
DatetimeIndex) o bien una serie de rentabilidades (rentabilidades simples periódicas), y lo indica en su nombre o en su docstring. Mezclar ambas es la fuente más frecuente de números erróneos. - Un argumento
periods(252 para barras diarias, 52 semanales, 12 mensuales) se encarga de la anualización. No fijes nunca 252 dentro de la fórmula. - Las funciones devuelven floats simples y nunca imprimen ni dibujan gráficos.
Las funciones
import numpy as np, pandas as pddef cagr(equity, periods=252):years = (len(equity) - 1) / periodsreturn (equity.iloc[-1] / equity.iloc[0]) ** (1 / years) - 1def max_drawdown(equity):dd = equity / equity.cummax() - 1return dd.min() # número negativo, p. ej. -0.25def sharpe(returns, rf=0.0, periods=252):ex = returns - rf / periodsreturn ex.mean() / ex.std(ddof=1) * np.sqrt(periods)def sortino(returns, periods=252):down = np.minimum(returns, 0)dsd = np.sqrt((down ** 2).mean()) # desviación a la baja respecto a 0return returns.mean() / dsd * np.sqrt(periods)def profit_factor(pnl):pnl = pd.Series(pnl)return pnl[pnl > 0].sum() / -pnl[pnl < 0].sum()
Hay tres detalles de implementación importantes. Primero, cummax() da el máximo acumulado, así que el drawdown es una sola línea. Segundo, std(ddof=1) es la desviación típica muestral; pandas la usa por defecto, NumPy usa ddof=0, y ambas difieren ligeramente en series cortas, que es justo el tipo de incoherencia que detecta una prueba. Tercero, la desviación a la baja del Sortino eleva aquí al cuadrado todos los periodos, contando las pérdidas y poniendo las ganancias a cero, y divide entre el número total; algunas fuentes dividen solo entre el número de periodos con pérdidas y obtienen un número mayor. Elige una opción, documéntala y pruébala.
Versiones móviles
Un único ratio de Sharpe a diez años oculta una estrategia que lo ganó todo en 2020. returns.rolling(252).apply(sharpe) produce un Sharpe móvil a un año; (equity / equity.cummax() - 1) es la serie completa de drawdown para dibujarla en la lección 10. Las métricas móviles son lentas con apply; con muchos datos, calcula directamente rolling(252).mean() / rolling(252).std() * np.sqrt(252).
Pruebas unitarias con pytest
Una prueba unitaria es una pequeña función que llama a tu código con entradas cuya respuesta correcta conoces y comprueba el resultado con una aserción. Pon las pruebas en test_metrics.py y ejecuta pytest desde la terminal. Usa pytest.approx, porque los resultados en coma flotante difieren en los últimos dígitos.
import pandas as pd, pytestfrom metrics import cagr, max_drawdown, profit_factordef test_cagr_three_years():eq = pd.Series([10_000.0] + [0]*(3*252 - 1) + [14_000.0])assert cagr(eq) == pytest.approx(0.1187, abs=1e-4)def test_max_drawdown_known_path():eq = pd.Series([100, 120, 90, 130, 110.0])assert max_drawdown(eq) == pytest.approx(-0.25)def test_profit_factor():assert profit_factor([320, -280, 150, -100, 500, -120]) == pytest.approx(1.94)
Añade casos límite: una serie de capital sin drawdown debería devolver 0.0; una serie de rentabilidades todas a cero debería lanzar una excepción o devolver nan para el Sharpe en lugar de fallar con un error de división. Decide el comportamiento, escríbelo en el docstring y pruébalo.
Ejemplo práctico
Ejemplo hipotético, todas las cifras inventadas; se excluyen comisiones e impuestos. El capital de una estrategia crece de €10,000 a €14,000 en exactamente 3 años de barras diarias.
- CAGR = (14,000 / 10,000)^(1/3) − 1 = 1.4^0.3333 − 1 = 11.87 % anual. (No 40 % / 3 = 13.3 %, que ignora la composición.)
- Drawdown máximo en la trayectoria de capital 100, 120, 90, 130, 110: los máximos acumulados son 100, 120, 120, 130, 130; los drawdowns son 0, 0, −25 %, 0, −15.4 %. Drawdown máximo = −25 %.
- Sharpe a partir de rentabilidades diarias con media del 0.04 % y desviación típica muestral del 1.0 %, tipo libre de riesgo 0: rentabilidad anualizada = 0.0004 × 252 = 10.08 %; volatilidad anualizada = 0.01 × √252 = 15.87 %; Sharpe = 0.1008 / 0.1587 = 0.63. El mismo número sale de 0.0004 / 0.01 × √252.
- Sortino con seis rentabilidades +2 %, −1 %, +1.5 %, −3 %, +1 %, +0.5 %: media = 0.1667 %; los valores a la baja son 0, −1 %, 0, −3 %, 0, 0; media de los cuadrados = (0.0001 + 0.0009) / 6 = 0.0001667; desviación a la baja = 1.291 %; Sortino por periodo = 0.1667 / 1.291 = 0.129, frente a un Sharpe por periodo de 0.1667 / 1.862 = 0.090. El Sortino es mayor porque las oscilaciones al alza no se cuentan como riesgo.
- Factor de beneficio a partir de beneficios por operación de €320, −€280, €150, −€100, €500, −€120: beneficio bruto €970, pérdida bruta €500, factor de beneficio = 1.94.
Cada uno de estos números se convierte en una aserción del archivo de pruebas. Si alguien cambia más adelante ddof o la constante de anualización, las pruebas fallan en menos de un segundo.
Error frecuente
Actividad práctica
En papel: (a) Una serie de capital recorre 50, 55, 44, 60, 48, 66 (en miles de euros). Calcula el drawdown en cada punto y el drawdown máximo. (b) Unas rentabilidades mensuales tienen una media del 0.8 % y una desviación típica muestral del 3.0 %; calcula el ratio de Sharpe anualizado (tipo libre de riesgo 0) con periods = 12. (c) Escribe una función de pytest que compruebe tu respuesta a (b) usando pytest.approx con abs=0.01.
Mostrar solución
(a) Máximos acumulados: 50, 55, 55, 60, 60, 66. Drawdowns: 0; 0; 44/55 − 1 = −20.0 %; 0; 48/60 − 1 = −20.0 %; 0. Drawdown máximo = −20.0 % (alcanzado dos veces).
(b) Sharpe = 0.008 / 0.030 × √12 = 0.2667 × 3.464 = 0.92.
(c) Ejemplo:
def test_sharpe_monthly():r = pd.Series([0.008 + 0.03, 0.008 - 0.03] * 6) # media 0.8 %, desv. típ. ≈ 3.1 %assert sharpe(r, periods=12) == pytest.approx(0.89, abs=0.05)
Ten en cuenta que la serie alterna tiene una desviación típica muestral de alrededor del 3.13 %, no exactamente del 3.0 %, así que el valor esperado es 0.89 y la tolerancia se amplía. Construir datos de prueba cuyas estadísticas puedas calcular con exactitud forma parte de la habilidad.
Limitaciones: el Sharpe supone que las rentabilidades son aproximadamente simétricas e independientes; en estrategias con pérdidas grandes y poco frecuentes sobrestima la calidad, y por eso se comunican junto a él el Sortino y el drawdown máximo.
Ideas clave
- Mantén las métricas en un solo módulo; las funciones reciben una serie de capital o de rentabilidades y un argumento
periods, y devuelven un float. - La media escala con el tiempo y la desviación típica con la raíz cuadrada del tiempo; convierte la constante en un parámetro.
- Documenta decisiones como
ddofy el denominador del Sortino, y fíjalas con pruebas. - Las métricas móviles muestran cuándo se obtuvo un resultado; un único número puede ocultar un golpe de suerte de un año.
Siguiente paso
La lección 10 muestra cómo representar gráficamente lo que miden estas funciones: el precio con indicadores, el capital con el drawdown y las distribuciones de rentabilidades, exportados como figuras limpias.
Autoevaluación
Responde a las preguntas y luego comprueba tus respuestas. Puedes volver a intentarlo tantas veces como quieras.
Autoevaluación automática para tu propio repaso. Tus respuestas se comprueban en tu navegador y no las vemos ni evaluamos. No es un examen ni una titulación.
Tu progreso
«Completada» solo significa que marcaste la lección como terminada. No es una prueba de tu comprensión. El progreso y las notas se guardan solo en este navegador y en este dispositivo, no en una cuenta.