---
title: "2.6.3 Informes con QuantStats y tearsheets propios"
description: "quantstats 0.0.86 frente a las métricas propias, su informe HTML, pyfolio-reloaded y un tearsheet propio cuyas figuras son las de esta lección."
url: "https://pythonparatrading.com/curso-python-trading/nivel-2/informes-quantstats-y-tearsheets/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-2/informes-quantstats-y-tearsheets.png"
---

# 2.6.3 · 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.

- Curso: [Nivel 2: Estrategias sistemáticas y backtesting riguroso](https://pythonparatrading.com/curso-python-trading/nivel-2/)
- Módulo: 2.6 · Métricas de rendimiento
- Duración: 55 min
- Requisitos: [2.6.1 · CAGR, Sharpe, Sortino, Calmar, Omega, drawdown y Ulcer Index](https://pythonparatrading.com/curso-python-trading/nivel-2/metricas-de-rentabilidad-y-riesgo/), [0.6.1 · Matplotlib orientado a objetos, Seaborn y Plotly: gráficos de precios, drawdown y velas](https://pythonparatrading.com/curso-python-trading/nivel-0/graficos-matplotlib-seaborn-y-plotly/)

## Al terminar esta lección

- Usar `quantstats.stats` y `quantstats.reports` (metrics y html) sobre una serie de rendimientos propia, sin red y sin avisos en la salida.
- Identificar las métricas de quantstats que no coinciden con las definiciones de la lección 2.6.1 (CAGR, Ulcer Performance Index, information ratio, beta diaria) y por qué.
- Saber cuándo usar pyfolio-reloaded y qué exige su instalación con pandas 3.
- Construir un tearsheet propio: tabla de métricas, rentabilidades mensuales, peores drawdowns y Sharpe móvil, con los datos listos para dibujarlos.
- Leer un Sharpe móvil de un año sabiendo que su error típico es de alrededor de 1.

## 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:

```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.

```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())
```
```output
5263 2005-02-01 2025-12-31
```

## quantstats: las métricas sueltas

quantstats[^1] (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:

```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__)
```
```output
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:

```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)
```
```output
         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 - 1$ en lugar de $N$, 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 libre[^2]. Con la acumulada, la cifra crece con los años de la muestra y no compara nada.

```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}")
```
```output
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 $\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.

```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"]]
```
```output
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:

```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))
```
```output
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.

<aside class="callout" data-kind="aviso">

`qs.reports.html` alinea por defecto la estrategia y el benchmark por fechas (`match_dates=True`). Con series de
calendarios distintos (divisas en días laborables frente a un índice en sesiones de la NYSE) recorta los días que no
coinciden. Las métricas de un informe así no son las de la serie completa: compara siempre con las calculadas sobre la
serie original.

</aside>

## pyfolio-reloaded

pyfolio-reloaded[^3] 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:

```python skip
# 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.

```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)
```
```output
                          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.

```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)
```
```output
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 %.* Tabla de colores con un año por fila, de 2019 a 2025, y un mes por columna, más una columna con la rentabilidad del año. 51 de los 84 meses son positivos. El peor mes, destacado, es marzo de 2020, con un −5,9 %. El mejor año es 2023, con un +31 %; el peor, 2025, con un −4 %.

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.

```python
episodios(r).sort_values("profundidad").head(5).round(3)
```
```output
         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.* Cinco barras horizontales hacia la izquierda. El peor, destacado: −19,4 %, de 2015 a 2018, 2,68 años bajo el agua. Le siguen −16,1 % (2007-2008, 1,25 años), −12,4 % (2012-2013, 0,46 años), −12,1 % (2025, 0,36 años) y −12,0 % (2006-2007, 0,69 años). Los cinco están cerrados a final de 2025.

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.

```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}")
```
```output
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.* Línea del Sharpe de las últimas 252 sesiones de TSMOM, semana a semana de 2006 a 2025, que oscila entre −1,5 y 3,2. Una línea destacada marca el Sharpe de toda la muestra, 1,03, y una banda gris de 1,03 ± 2 marca lo que cabe esperar por azar con un año de datos si el Sharpe verdadero fuera ese: la línea solo sale de ella en el 1 % de las ventanas. El 12,3 % de las ventanas tiene Sharpe negativo.

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.

```mermaid
flowchart TB
  F[Funciones pandas] --> T[Tablas]
  T --> J[Datos JSON]
  J --> V[Spec Vega-Lite]
  V --> SVG[SVG en build]
```

```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")
```
```output
{'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.**

```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}")
```
```output
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 $\eta(12)$ queda en 3,717, por
encima de $\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.

2. 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.**

```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})")
```
```output
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 ($\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.

3. 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.

[^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/>.

## 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é?

   - A. Porque quantstats calcula mal el Ulcer Index
   - 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
   - C. Porque una usa rendimientos logarítmicos
   - D. Porque quantstats anualiza el Ulcer Index

   *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?

   - A. El information ratio anual
   - B. La media diaria de la rentabilidad activa entre su desviación típica diaria, sin anualizar: por √252 serían 0,049
   - C. La correlación con el benchmark
   - D. El tracking error

   *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?

   - A. Porque está abandonado
   - 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
   - C. Porque solo funciona con Zipline
   - D. Porque necesita conexión a internet

   *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?

   - A. Que la estrategia cambia de régimen cada año
   - 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
   - C. Que hay que operar solo cuando el Sharpe móvil es positivo
   - D. Que el Sharpe de toda la muestra es erróneo

   *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?

   - A. Nada: el de quantstats es completo
   - 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
   - C. Más colores
   - D. Solo el CAGR

   *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

### Research

- [Zipline en Python - Parte 1: Quant Stack y Pipeline.](https://pythonparatrading.com/zipline-backtesting-con-python-quant-stack/) · Research-0011 · 9 min

### Documentación

- [QuantStats: código fuente y documentación (Apache 2.0)](https://github.com/ranaroussi/quantstats) · github.com
- [quantstats 0.0.86 en PyPI (metadatos y dependencias)](https://pypi.org/project/quantstats/0.0.86/) · pypi.org
- [pyfolio-reloaded: código fuente (Apache 2.0)](https://github.com/stefan-jansen/pyfolio-reloaded) · github.com
- [pyfolio-reloaded en PyPI (requisitos por versión de Python)](https://pypi.org/project/pyfolio-reloaded/) · pypi.org
- [Vega-Lite: documentación](https://vega.github.io/vega-lite/docs/) · vega.github.io

### Datos

- [Datos del nivel 2: futuros continuos, 2000-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_continuos.parquet) · Descarga · futuros2_continuos.parquet

Lección anterior: [2.6.2 · Win rate, payoff, expectativa, alpha, beta, information ratio, turnover y capacidad](https://pythonparatrading.com/curso-python-trading/nivel-2/metricas-de-operaciones-alpha-beta-y-capacidad/)
