Lección 2.6.3 · 55 min

Informes con QuantStats o pyfolio-reloaded y tearsheets propios

Un tearsheet reúne en una página las métricas y los gráficos de una estrategia. quantstats lo genera con una llamada; esta lección comprueba qué calcula cada una de sus cifras, dónde usa convenciones distintas de las de la lección 2.6.1, y construye un tearsheet propio con pandas y Vega: sus cuatro paneles son las figuras de la lección.

Fig. — Tearsheet propio, panel 1: capital de TSMOM frente al futuro del índice IDA (escala logarítmica) y drawdown de TSMOM, febrero de 2005 a 2025
Lecciones del nivel 2
  1. 2.1 · Estadística y econometría para trading

    1. 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
    2. 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
    3. 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
    4. 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
    5. 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
    6. 2.1.6 Filtro de Kalman para hedge ratios dinámicos
    7. 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
    8. 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
  2. 2.2 · Anatomía de un sistema de trading

    1. 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
    2. 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
    3. 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
    4. 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
  3. 2.3 · Familias de estrategias

    1. 2.3.1 Seguimiento de tendencia y momentum
    2. 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
    3. 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
    4. 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
    5. 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
    6. 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
    7. 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
  4. 2.4 · Backtesting: motores y paradigmas

    1. 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
    2. 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
    3. 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
    4. 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
    5. 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
    6. 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
    7. 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
    8. 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
  5. 2.5 · Sesgos y validación

    1. 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
    2. 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
    3. 2.5.3 Validación cruzada combinatoria purgada para estrategias
    4. 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
    6. 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
    7. 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
    8. 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
  6. 2.6 · Métricas de rendimiento

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown y Ulcer Index
    2. 2.6.2 Win rate, payoff, expectativa, alpha, beta, information ratio, turnover y capacidad
    3. 2.6.3 Informes con QuantStats o pyfolio-reloaded y tearsheets propios

Qué es un tearsheet

Un tearsheet (literalmente, «hoja para arrancar») es el resumen de una estrategia en una página: una tabla de métricas y unos pocos gráficos que se leen de un vistazo. El nombre se extendió con pyfolio, la librería de Quantopian, y hoy casi cualquier librería de backtesting tiene el suyo. Todos tienen piezas parecidas:

RendimientosTabla de métricasBenchmarkCapital y DDTabla mensualPeores DDSharpe móvil
Fuente Mermaid
flowchart LR
  R[Rendimientos] --> M[Tabla de métricas]
  B[Benchmark] --> M
  R --> C[Capital y DD]
  B --> C
  R --> MM[Tabla mensual]
  R --> P[Peores DD]
  R --> S[Sharpe móvil]

Las métricas son las de las lecciones 2.6.1 y 2.6.2. La utilidad de una librería es el formato y la rapidez; el riesgo, que cada cifra viene con una convención que el informe no dice. Esta lección hace las dos cosas: genera el informe de quantstats y comprueba qué hay detrás de cada número, y después construye uno propio. La estrategia es la TSMOM en 24 futuros de la lección 2.6.1, sobre los datos sintéticos del nivel 2 (con el efecto de momentum plantado), frente al futuro del índice IDA como benchmark.

IN [01]python
import numpy as np
import pandas as pd

fut = pd.read_parquet("futuros2_continuos.parquet")
px = fut.pivot(index="fecha", columns="mercado",
               values="cierre_ajustado")
r_fut = px.pct_change()
vol = r_fut.ewm(span=60, min_periods=60).std() * np.sqrt(252)
senal = np.sign(px / px.shift(252) - 1)
peso = (senal * 0.40 / px.shape[1] / vol).shift(1)

r = (peso * r_fut).sum(axis=1, min_count=1).loc["2005-02-01":]
r.name = "TSMOM"
ref = r_fut["IDA"].loc["2005-02-01":].rename("IDA")
print(len(r), r.index[0].date(), r.index[-1].date())
OUT [01]TSMOM y el índice IDA
5263 2005-02-01 2025-12-31

quantstats: las métricas sueltas

quantstats1Nota 1 · Ran Aroussi, QuantStats, https://github.com/ranaroussi/quantstats; versión, fecha y dependencias en https://pypi.org/project/quantstats/0.0.86/. (Apache 2.0, versión 0.0.86 del 27 de septiembre de 2026 en PyPI) tiene tres módulos: qs.stats con las métricas, qs.plots con los gráficos y qs.reports con los informes. Al dibujar pide la fuente Arial a matplotlib; en un sistema sin ella, cada figura escribe findfont: Font family 'Arial' not found en el log, decenas de líneas que ensucian la salida. El aviso es inofensivo (matplotlib usa otra fuente) y se silencia de forma determinista subiendo el nivel de ese logger concreto, sin tocar el resto de avisos:

IN [02]python
import logging
# quantstats pide Arial a matplotlib; si no está instalada, cada
# figura escribe «findfont: Font family 'Arial' not found».
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)

import quantstats as qs
print(qs.__version__)
OUT [02]quantstats sin avisos de fuentes
0.0.86

Las funciones de qs.stats reciben una serie de rendimientos con índice de fechas. Frente a las de la lección 2.6.1:

IN [03]python
def cagr_fechas(r):
    anos = (r.index[-1] - r.index[0]).days / 365.25
    return (1 + r).prod() ** (1 / anos) - 1

def sharpe(r, n=252):
    return r.mean() / r.std() * np.sqrt(n)

def sortino(r, n=252):
    abajo = np.sqrt((r.clip(upper=0) ** 2).mean())
    return r.mean() / abajo * np.sqrt(n)

def drawdown(r):
    capital = (1 + r).cumprod()
    return capital / capital.cummax().clip(lower=1.0) - 1

def ulcer(r):
    return np.sqrt((drawdown(r) ** 2).mean())

propias = {"cagr": cagr_fechas(r), "sharpe": sharpe(r),
           "sortino": sortino(r), "dd_max": drawdown(r).min(),
           "calmar": cagr_fechas(r) / -drawdown(r).min(),
           "ulcer": ulcer(r), "martin": cagr_fechas(r) / ulcer(r)}
de_qs = {"cagr": qs.stats.cagr(r), "sharpe": qs.stats.sharpe(r),
         "sortino": qs.stats.sortino(r),
         "dd_max": qs.stats.max_drawdown(r),
         "calmar": qs.stats.calmar(r),
         "ulcer": qs.stats.ulcer_index(r),
         "martin": qs.stats.ulcer_performance_index(r)}
pd.DataFrame({"propia": propias, "quantstats": de_qs}).round(4)
OUT [03]Las métricas de la lección 2.6.1 frente a qs.stats
         propia  quantstats
cagr     0.1239      0.1241
sharpe   1.0256      1.0256
sortino  1.5272      1.5272
dd_max  -0.1938     -0.1938
calmar   0.6394      0.6403
ulcer    0.0642      0.0642
martin   1.9312    163.7887
  • Sharpe, Sortino, drawdown máximo y Ulcer Index coinciden con las propias hasta el cuarto decimal. El Ulcer de quantstats divide por N−1N - 1 en lugar de NN, una diferencia invisible con 5.263 sesiones.
  • CAGR y Calmar difieren en el cuarto decimal: quantstats cuenta los años como sesiones entre 252 (lo dice su propio código), como empyrical. En futuros apenas se nota; en divisas o cripto, sí (lección 2.6.1).
  • El «Ulcer Performance Index» no es el ratio de Martin: 163,79 frente a 1,93. quantstats divide por el Ulcer Index la rentabilidad acumulada de toda la muestra (un 1.051 %), y Martin define el UPI con la rentabilidad anual menos el tipo libre2Nota 2 · Peter G. Martin, «Ulcer Index», https://www.tangotools.com/ui/ui.htm: UPI = (rentabilidad total anualizada − rentabilidad libre de riesgo) / UI.. Con la acumulada, la cifra crece con los años de la muestra y no compara nada.
IN [04]python
def mensual(x):
    return (1 + x).resample("ME").prod() - 1

activa = mensual(r) - mensual(ref)
ir_mensual = activa.mean() / activa.std() * np.sqrt(12)
g = qs.stats.greeks(r, ref)
m = pd.concat([mensual(r), mensual(ref)], axis=1)
beta_m = m.cov().iloc[0, 1] / m.iloc[:, 1].var()
ir_qs = qs.stats.information_ratio(r, ref)
print(f"IR de quantstats (diario): {ir_qs:.4f}")
print(f"El mismo por raíz de 252:  {ir_qs * np.sqrt(252):.4f}")
print(f"IR mensual anualizado:     {ir_mensual:.4f}")
print(f"beta de quantstats (diaria): {g['beta']:.3f}")
print(f"beta mensual:                {beta_m:.3f}")
OUT [04]Information ratio y beta: en diario y sin anualizar
IR de quantstats (diario): 0.0031
El mismo por raíz de 252:  0.0492
IR mensual anualizado:     0.0196
beta de quantstats (diaria): 0.070
beta mensual:                0.089

El information ratio de quantstats es la media diaria de la rentabilidad activa entre su desviación típica diaria, sin anualizar: 0,0031, que por 252\sqrt{252} serían 0,049. Con datos mensuales, 0,020. Las tres cifras dicen lo mismo (TSMOM y el índice rinden parecido en media, un 12,4 % y un 11,3 % anual por 252 sesiones, y la rentabilidad activa es muy volátil, así que el IR ronda cero), pero en una tabla junto a IR anuales de otra fuente la de quantstats parecería dieciséis veces menor. La beta también es diaria: 0,070 frente a 0,089 en mensual. Aquí se parecen, pero no tienen por qué: las posiciones de TSMOM tardan semanas en girarse, y la relación con el índice puede ser distinta a un mes que a un día. Antes de comparar una beta, pregunta su frecuencia.

El informe completo: metrics y html

qs.reports.metrics devuelve la tabla de un informe (81 filas en modo full) como DataFrame, y qs.reports.html la escribe en un archivo HTML junto a los gráficos.

IN [05]python
tabla = qs.reports.metrics(r, benchmark=ref, mode="full",
                           display=False)
print(len(tabla), "filas")
tabla.loc[["Sharpe", "Smart Sharpe", "Sortino", "Max Drawdown",
           "Longest DD Days", "Calmar", "Ulcer Index",
           "Ulcer Performance Index", "Payoff Ratio", "Profit Factor",
           "Beta", "Alpha"]]
OUT [05]La tabla de qs.reports.metrics
81 filas
                        Benchmark Strategy
Sharpe                       0.56     1.03
Smart Sharpe                 0.55     1.02
Sortino                      0.81     1.53
Max Drawdown                -0.62    -0.19
Longest DD Days              1394      980
Calmar                       0.16     0.64
Ulcer Index                  0.18     0.06
Ulcer Performance Index     33.37   163.79
Payoff Ratio                  1.0     1.04
Profit Factor                1.11     1.18
Beta                            -     0.07
Alpha                           -     0.12

Las cifras son las de qs.stats, redondeadas a dos decimales y convertidas en texto (dtype object): sirven para leer, no para seguir calculando. «Longest DD Days» son días de calendario (980 en TSMOM, del 4 de agosto de 2015 al 10 de abril de 2018), no sesiones. «Payoff Ratio» y «Profit Factor» se calculan sobre los días, no sobre las operaciones de la lección 2.6.2. «Smart Sharpe» penaliza el Sharpe por la autocorrelación de orden 1 de los rendimientos diarios (el primer ejercicio lo compara con la corrección de Lo). Una de las etiquetas de la tabla, la del CAGR, lleva un signo de porcentaje Unicode poco común (U+FE6A) que no está en todas las fuentes: conviene renombrarla antes de publicar la tabla.

El informe HTML se escribe a una carpeta temporal y se mide, en lugar de imprimirse:

IN [06]python
import re
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as carpeta:
    ruta = Path(carpeta) / "tsmom.html"
    qs.reports.html(r, benchmark=ref, output=str(ruta),
                    title="TSMOM frente a IDA")
    html = ruta.read_text(encoding="utf-8")

print(f"{len(html) / 1e6:.1f} MB")
print("figuras SVG:", html.count("<svg"),
      "· tablas:", html.count("<table"))
print(re.findall(r"<h3>([^<]*)</h3>", html))
OUT [06]El informe HTML, a un archivo temporal
2.0 MB
figuras SVG: 14 · tablas: 3
['Key Performance Metrics', 'EOY Returns vs Benchmark', 'Worst 10 Drawdowns']

Son 2,0 MB con 14 figuras SVG incrustadas (rentabilidad acumulada en escala normal, logarítmica y ajustada por volatilidad, rentabilidades anuales, rendimientos diarios, distribuciones, beta, volatilidad, Sharpe y Sortino móviles, periodos de drawdown, gráfico bajo el agua y mapa de calor mensual) y tres tablas: las métricas, la rentabilidad de cada año frente al benchmark y los diez peores drawdowns. No necesita red cuando se le pasan series propias; quantstats incluye yfinance entre sus dependencias para cuando se le pasa un ticker como benchmark.

pyfolio-reloaded

pyfolio-reloaded3Nota 3 · Stefan Jansen, pyfolio-reloaded, https://github.com/stefan-jansen/pyfolio-reloaded; requisitos por versión de Python en https://pypi.org/project/pyfolio-reloaded/. es la continuación mantenida de pyfolio, la librería de tearsheets de Quantopian, y usa empyrical-reloaded para las métricas. Su punto fuerte son los tearsheets de posiciones y operaciones: exposición por activo, rotación, operaciones de ida y vuelta (round_trips) y capacidad (create_capacity_tear_sheet, con límites de participación en el volumen como los de la lección 2.6.2). Su versión 0.10.0 (17 de octubre de 2026) exige pandas<3 en Python 3.14 según sus metadatos de PyPI, así que no se instala en el entorno principal del curso (pandas 3.0.6). El código, que se ejecutaría en un entorno aparte con pandas 2.x como el de Zipline de la lección 2.4.6:

IN [07]python
# pyfolio-reloaded 0.10.0 exige pandas < 3 con Python 3.14: necesita un
# entorno propio (como el de Zipline), no el principal del curso.
import pyfolio as pf

pf.create_returns_tear_sheet(r, benchmark_rets=ref)
pf.show_perf_stats(r, factor_returns=ref)

create_returns_tear_sheet dibuja en una sola figura de matplotlib el capital frente al benchmark, los drawdowns, la beta y el Sharpe móviles, las rentabilidades mensuales y anuales y la distribución de los rendimientos; show_perf_stats imprime la tabla de métricas. Para usarlo desde el entorno principal, lo práctico es guardar los rendimientos en Parquet y ejecutar el tearsheet en el otro entorno.

Un tearsheet propio

Un tearsheet propio tiene tres ventajas sobre el de una librería: cada cifra tiene la definición que se ha decidido (y probado en la lección 2.6.1), incluye las métricas que importan para esa estrategia y no las ochenta de un informe genérico, y su salida son datos que se pueden dibujar con cualquier herramienta. El de esta lección son cuatro funciones de pandas y cuatro figuras Vega.

IN [08]python
def episodios(r):
    dd = drawdown(r)
    bajo = dd < 0
    grupo = (~bajo).cumsum()[bajo]
    filas = []
    for _, e in dd[bajo].groupby(grupo):
        sig = dd.index.searchsorted(e.index[-1], side="right")
        fin = dd.index[min(sig, len(dd) - 1)]
        filas.append({"inicio": e.index[0].date(),
                      "minimo": e.idxmin().date(),
                      "recupera": fin.date() if sig < len(dd) else None,
                      "profundidad": e.min(),
                      "anos": (fin - e.index[0]).days / 365.25})
    return pd.DataFrame(filas)

def metricas(r):
    m = mensual(r)
    return {"CAGR": cagr_fechas(r),
            "Volatilidad": r.std() * np.sqrt(252),
            "Sharpe": sharpe(r), "Sortino": sortino(r),
            "DD máximo": drawdown(r).min(),
            "Años bajo el agua (máx.)": episodios(r)["anos"].max(),
            "Ulcer": ulcer(r),
            "Calmar": cagr_fechas(r) / -drawdown(r).min(),
            "Asimetría mensual": m.skew(),
            "Peor mes": m.min(), "Meses positivos": (m > 0).mean()}

pd.DataFrame({"TSMOM": metricas(r), "IDA": metricas(ref)}).round(3)
OUT [08]Tearsheet propio: tabla de métricas
                          TSMOM    IDA
CAGR                      0.124  0.097
Volatilidad               0.121  0.203
Sharpe                    1.026  0.559
Sortino                   1.527  0.805
DD máximo                -0.194 -0.622
Años bajo el agua (máx.)  2.683  3.817
Ulcer                     0.064  0.178
Calmar                    0.639  0.156
Asimetría mensual         0.156 -2.151
Peor mes                 -0.082 -0.483
Meses positivos           0.598  0.641

La tabla resume lo que importa de TSMOM frente a su benchmark: más CAGR (12,4 % frente a 9,7 %) con tres quintos de la volatilidad, un Sharpe de 1,03 frente a 0,56, un drawdown máximo del −19,4 % frente al −62,2 % y menos años bajo el agua en el peor episodio (2,7 frente a 3,8). La asimetría mensual los separa: 0,16 en TSMOM y −2,15 en el índice, cuyo peor mes es un −48,3 % (marzo de 2020). El peor mes de TSMOM es un −8,2 % (agosto de 2015), y el 59,8 % de sus meses son positivos, menos que en el índice (64,1 %): TSMOM acierta menos meses y pierde menos en los malos. La figura de la cabecera es su panel de capital y drawdown.

IN [09]python
m = mensual(r)
tabla_m = (m.groupby([m.index.year, m.index.month]).first()
           .unstack() * 100).rename_axis(index="año", columns="mes")
tabla_m["Año"] = ((1 + r).groupby(r.index.year).prod() - 1) * 100
tabla_m.tail(4).round(1)
OUT [09]Rentabilidades mensuales y anuales
mes     1    2    3    4    5    6    7    8    9   10   11   12   Año
año
2022  2.5 -2.9 -0.2  3.0  0.9  4.2  1.9  3.0  1.7 -2.3 -2.9 -0.5   8.4
2023 -3.0  1.4  1.2  1.5  8.0 -3.3 -1.2  2.9  2.4  9.1  6.4  2.7  31.1
2024  1.3 -3.9  8.6 -2.6  3.6 -0.0  5.2  0.9  2.0  6.1  1.8  1.1  26.0
2025  3.4  0.0 -4.8  1.3  1.8  5.3 -2.1 -2.9  2.4 -1.4 -3.7 -3.2  -4.3
Fig. — Tearsheet propio, panel 2: rentabilidad mensual de TSMOM de 2019 a 2025 (azul, meses positivos; naranja, negativos; más intenso, más del 2 % y del 5 %) y la del año completo, en %

La tabla mensual es la pieza que más se mira de un tearsheet y la que más engaña: los ojos buscan patrones de calendario donde solo hay ruido. En estos datos, 2023 suma un +31,1 % y 2025 un −4,3 %; el peor mes de los siete años de la figura es marzo de 2020 (−5,9 %). Ninguna de esas cifras dice nada de 2026.

IN [10]python
episodios(r).sort_values("profundidad").head(5).round(3)
OUT [10]Peores drawdowns
         inicio      minimo    recupera  profundidad   anos
104  2015-08-04  2017-08-23  2018-04-10       -0.194  2.683
21   2007-06-25  2007-12-24  2008-09-22       -0.161  1.246
51   2012-10-02  2013-01-28  2013-03-19       -0.124  0.460
199  2025-02-07  2025-03-18  2025-06-18       -0.121  0.359
15   2006-08-29  2007-01-08  2007-05-09       -0.120  0.693
Fig. — Tearsheet propio, panel 4: los cinco peores drawdowns de TSMOM, con su profundidad, los años que abarcan y su duración bajo el agua

Los cinco peores drawdowns ordenados por profundidad, con su duración: el primero (−19,4 %, de agosto de 2015 a abril de 2018) dura 2,7 años; el segundo (−16,1 %, de 2007 a 2008), 1,2; los otros tres, entre el −12,4 % y el −12,0 %, se recuperan en menos de un año. Los cinco están cerrados, pero justo detrás hay uno abierto: desde el 26 de junio de 2025, TSMOM está un 11,8 % por debajo de su máximo, y el 31 de diciembre es su mínimo. Un tearsheet tiene que distinguir un episodio cerrado de uno en curso, cuya profundidad y duración solo pueden crecer.

IN [11]python
sm = (r.rolling(252).mean() / r.rolling(252).std() * np.sqrt(252))
sm = sm.dropna()
print(f"Sharpe de toda la muestra: {sharpe(r):.2f}")
print(f"Sharpe a un año: de {sm.min():.2f} a {sm.max():.2f}")
print(f"Ventanas con Sharpe negativo: {(sm < 0).mean():.1%}")
print(f"Desviación típica del Sharpe a un año: {sm.std():.2f}")
OUT [11]Sharpe móvil de un año
Sharpe de toda la muestra: 1.03
Sharpe a un año: de -1.52 a 3.24
Ventanas con Sharpe negativo: 12.3%
Desviación típica del Sharpe a un año: 0.91
Fig. — Tearsheet propio, panel 3: Sharpe móvil de un año de TSMOM frente al de toda la muestra, con la banda de ± 2 errores típicos que tiene una ventana de un año

El Sharpe de las últimas 252 sesiones va de −1,52 a 3,24 y es negativo en el 12,3 % de las ventanas, con un Sharpe de toda la muestra de 1,03 y un efecto plantado en los datos. No es que la estrategia «deje de funcionar» un año de cada ocho: con 252 sesiones el error típico de un Sharpe anual es de alrededor de 1 (lección 2.6.1), y la desviación típica del Sharpe a un año de TSMOM, 0,91, es de ese orden. La banda gris de la figura, el Sharpe de la muestra ± 2, es lo que cabe esperar del azar; la línea solo sale de ella en el 1 % de las ventanas. Un Sharpe móvil sin esa banda invita a ver regímenes donde hay ruido.

Funciones pandasTablasDatos JSONSpec Vega-LiteSVG en build
Fuente Mermaid
flowchart TB
  F[Funciones pandas] --> T[Tablas]
  T --> J[Datos JSON]
  J --> V[Spec Vega-Lite]
  V --> SVG[SVG en build]
IN [12]python
import json

semana = lambda x: x.resample("W-FRI").last()
capital = semana((1 + pd.concat([r, ref], axis=1)).cumprod())
datos = {
    "capital": capital.round(3).reset_index().astype({"fecha": str})
                      .to_dict("records"),
    "drawdown": (100 * semana(drawdown(r))).round(1).tolist(),
    "mensual": tabla_m.round(1).stack().dropna().reset_index()
                      .set_axis(["año", "mes", "r"], axis=1)
                      .to_dict("records"),
    "sharpe_movil": semana(sm).round(2).tolist(),
}
print({k: len(v) for k, v in datos.items()})
print(f"{len(json.dumps(datos)) / 1e3:.0f} KB de datos")
OUT [12]De las tablas a las figuras
{'capital': 1092, 'drawdown': 1092, 'mensual': 272, 'sharpe_movil': 1040}
84 KB de datos

La última celda deja los datos listos para dibujar: capital y drawdown semanales, la tabla mensual en formato largo y el Sharpe móvil. Las figuras de esta lección se hicieron así: esos datos, submuestreados para que cada SVG pese menos de 12 KB, van dentro de un spec de Vega-Lite que el sitio compila a SVG en el build (por eso la tabla mensual de la figura se queda en siete años). Con matplotlib o plotly (lección 0.6.1) el paso final sería otro, pero la separación es la misma: primero las cifras, comprobadas; después el dibujo.

Qué debe tener un buen tearsheet

  • El periodo, la frecuencia y las convenciones a la vista: sesiones por año, tipo libre restado o no, benchmark.
  • Las métricas de la forma de la estrategia, no solo las de dispersión: asimetría mensual, peores meses, duración del drawdown. Para un carry, la cola izquierda; para una tendencia, las rachas planas.
  • El error de cada estimación: el Sharpe con su intervalo, el Sharpe móvil con su banda.
  • Los costes: bruto y neto, con el turnover que los genera (lección 2.6.2).
  • La separación entre in-sample y out-of-sample, si la hay (lección 2.5.2): un tearsheet que mezcla los dos periodos sin marcarlos es un folleto.
  • Ninguna cifra sin su definición. Si viene de una librería, la versión de la librería.

Ejercicios

  1. Compara el «Smart Sharpe» de quantstats con el Sharpe anualizado con la corrección de Lo (2002) de la lección 2.6.1. ¿Qué autocorrelación usa cada uno?
Solución
IN [13]python
mm = mensual(r)
rho = [mm.autocorr(k) for k in range(1, 12)]
eta = 12 / np.sqrt(12 + 2 * sum((12 - k) * rho[k - 1]
                                for k in range(1, 12)))
print(f"autocorrelación diaria de orden 1: {r.autocorr(1):.3f}")
print(f"autocorrelación mensual de orden 1: {rho[0]:.3f}")
print(f"Sharpe {qs.stats.sharpe(r):.3f} · "
      f"smart {qs.stats.smart_sharpe(r):.3f}")
print(f"Lo con datos mensuales: {mm.mean() / mm.std() * eta:.3f}")
OUT [13]Smart Sharpe frente a Lo
autocorrelación diaria de orden 1: 0.009
autocorrelación mensual de orden 1: -0.048
Sharpe 1.026 · smart 1.016
Lo con datos mensuales: 1.109

Smart Sharpe usa una sola autocorrelación, la de orden 1 de los rendimientos diarios (0,009), y supone que las demás decaen como sus potencias: baja el Sharpe de 1,026 a 1,016. Lo usa las autocorrelaciones mensuales de los retardos 1 a 11; la de orden 1 es −0,048 y la suma ponderada de todas es negativa, así que el factor η(12)\eta(12) queda en 3,717, por encima de 12\sqrt{12}: el Sharpe mensual pasa de 1,033 a 1,109. Las dos correcciones van en direcciones opuestas por una diferencia de diseño: Smart Sharpe toma el valor absoluto de la autocorrelación, así que penaliza cualquier autocorrelación, también la negativa; en la fórmula de Lo, una autocorrelación negativa sube el Sharpe anual.

  1. Calcula el Sharpe móvil con ventanas de 63 sesiones (un trimestre). ¿En qué fracción de las ventanas es negativo y cuánto vale su desviación típica?
Solución
IN [14]python
s63 = (r.rolling(63).mean() / r.rolling(63).std()
       * np.sqrt(252)).dropna()
print(f"Ventanas negativas: {(s63 < 0).mean():.1%}")
print(f"Desviación típica: {s63.std():.2f} "
      f"(esperada, unos {np.sqrt(252 / 63):.1f})")
OUT [14]Sharpe móvil de un trimestre
Ventanas negativas: 29.1%
Desviación típica: 1.96 (esperada, unos 2.0)

Con un trimestre, el Sharpe anualizado es negativo en el 29,1 % de las ventanas y su desviación típica es 1,96, casi exactamente los 2 de la fórmula (252/63\sqrt{252/63}: el error típico de un Sharpe anual con un cuarto de año de datos). Con un trimestre de datos, una estrategia con un Sharpe de 1 y un efecto plantado tiene un trimestre negativo de cada tres o cuatro. Por eso un informe trimestral no dice si una estrategia funciona.

  1. Diseña el tearsheet del carry de divisas de la lección 2.6.1. ¿Qué cambiarías respecto al de TSMOM?
Solución

La anualización: 261 días laborables al año, no 252, y el CAGR por fechas (quantstats y empyrical lo infravaloran un 0,14 % anual en esa serie). El benchmark: un índice de renta variable no es la referencia natural de un carry; mejor el propio diferencial de tipos o nada, y si se compara con IDA, cuidado con match_dates, porque los calendarios no coinciden. Las métricas de forma, delante: asimetría y curtosis mensuales, los cinco peores meses con sus fechas (los crashes), el Calmar y la duración del peor drawdown (9,8 años). El Sharpe con su error típico corregido por asimetría (0,239 frente a 0,219) y por autocorrelación (0,35 en lugar de 0,41). Y un panel que en TSMOM no hace falta: la rentabilidad acumulada en los tramos de crisis frente al resto, que es donde se decide si una estrategia con asimetría negativa se puede operar.

Footnotes

  1. Ran Aroussi, QuantStats, https://github.com/ranaroussi/quantstats; versión, fecha y dependencias en https://pypi.org/project/quantstats/0.0.86/. ↩

  2. Peter G. Martin, «Ulcer Index», https://www.tangotools.com/ui/ui.htm: UPI = (rentabilidad total anualizada − rentabilidad libre de riesgo) / UI. ↩

  3. Stefan Jansen, pyfolio-reloaded, https://github.com/stefan-jansen/pyfolio-reloaded; requisitos por versión de Python en https://pypi.org/project/pyfolio-reloaded/. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. qs.stats.ulcer_performance_index da 163,79 para TSMOM y el ratio de Martin de la lección 2.6.1 da 1,93. ¿Por qué?

    • APorque quantstats calcula mal el Ulcer Index
    • BPorque quantstats divide la rentabilidad acumulada de toda la muestra (un 1.051 %) entre el Ulcer Index, y Martin divide la rentabilidad anual menos el tipo libre
    • CPorque una usa rendimientos logarítmicos
    • DPorque quantstats anualiza el Ulcer Index
    Ver respuesta

    Respuesta B. Porque quantstats divide la rentabilidad acumulada de toda la muestra (un 1.051 %) entre el Ulcer Index, y Martin divide la rentabilidad anual menos el tipo libre

    El Ulcer Index de los dos coincide (0,0642). El numerador no: con la rentabilidad acumulada, el «UPI» de quantstats crece con la longitud de la muestra y no se puede comparar entre periodos. Es una diferencia de definición que solo se ve leyendo el código.

  2. qs.stats.information_ratio(r, ref) devuelve 0,0031. ¿Qué es esa cifra?

    • AEl information ratio anual
    • BLa media diaria de la rentabilidad activa entre su desviación típica diaria, sin anualizar: por √252 serían 0,049
    • CLa correlación con el benchmark
    • DEl tracking error
    Ver respuesta

    Respuesta B. La media diaria de la rentabilidad activa entre su desviación típica diaria, sin anualizar: por √252 serían 0,049

    quantstats no anualiza el IR. Comparado con un IR anual de otra fuente, parece 16 veces menor. Pasa algo parecido con la beta: la de quantstats es diaria (0,070) y la mensual es 0,089.

  3. ¿Por qué la lección no ejecuta pyfolio-reloaded?

    • APorque está abandonado
    • BPorque su versión 0.10.0 exige pandas < 3 en Python 3.14, y el entorno principal del curso tiene pandas 3.0.6: hace falta un entorno aparte
    • CPorque solo funciona con Zipline
    • DPorque necesita conexión a internet
    Ver respuesta

    Respuesta B. Porque su versión 0.10.0 exige pandas < 3 en Python 3.14, y el entorno principal del curso tiene pandas 3.0.6: hace falta un entorno aparte

    Lo dicen sus metadatos en PyPI. Funciona igual que en el entorno de Zipline del curso: otro entorno uv con pandas 2.x, al que se pasan los rendimientos como archivo.

  4. El Sharpe de un año de TSMOM va de −1,5 a 3,2 aunque el de toda la muestra sea 1,03. ¿Qué conclusión es correcta?

    • AQue la estrategia cambia de régimen cada año
    • BQue con 252 sesiones el error típico del Sharpe anual es de alrededor de 1, así que esas oscilaciones caben casi todas dentro de lo que produce el azar
    • CQue hay que operar solo cuando el Sharpe móvil es positivo
    • DQue el Sharpe de toda la muestra es erróneo
    Ver respuesta

    Respuesta B. Que con 252 sesiones el error típico del Sharpe anual es de alrededor de 1, así que esas oscilaciones caben casi todas dentro de lo que produce el azar

    El error típico del Sharpe anual es de unos 1/√años (lección 2.6.1): con un año, 1. La desviación típica del Sharpe a un año de TSMOM es 0,91, del mismo orden. Un Sharpe móvil sin su banda invita a ver regímenes donde hay ruido.

  5. ¿Qué debería añadir un tearsheet propio que no traiga el de quantstats para una estrategia de carry?

    • ANada: el de quantstats es completo
    • BLas métricas de forma y de cola que importan para una estrategia con asimetría negativa (asimetría mensual, peores meses, duración del drawdown), el error típico del Sharpe y la convención de anualización de su mercado
    • CMás colores
    • DSolo el CAGR
    Ver respuesta

    Respuesta B. Las métricas de forma y de cola que importan para una estrategia con asimetría negativa (asimetría mensual, peores meses, duración del drawdown), el error típico del Sharpe y la convención de anualización de su mercado

    Un tearsheet genérico pone las mismas cifras a todas las estrategias. Uno propio pone las que deciden si esa estrategia en concreto se opera: para el carry, la cola izquierda; para TSMOM, la duración de las rachas planas.

Ideas clave

  • quantstats 0.0.86 calcula Sharpe, Sortino, drawdown máximo y Ulcer Index igual que las funciones propias; el CAGR cuenta los años como sesiones entre 252.
  • Su «Ulcer Performance Index» usa la rentabilidad acumulada de toda la muestra, no la anual: no es el ratio de Martin. Su information ratio y su beta son diarios y sin anualizar.
  • qs.reports.html escribe un informe de 2,0 MB con 14 figuras SVG y tres tablas, sin red; los avisos findfont se silencian con el logger de matplotlib.font_manager.
  • pyfolio-reloaded 0.10.0 exige pandas < 3 con Python 3.14: se usa en un entorno aparte.
  • Un tearsheet propio son unas pocas funciones de pandas cuyas salidas se dibujan: métricas, capital y drawdown, tabla mensual, peores drawdowns y Sharpe móvil con su banda de error.
  • Un Sharpe de un año tiene un error típico de alrededor de 1: sin la banda, un Sharpe móvil engaña.

Recursos

Datos