---
title: "2.6.3 QuantStats reports and custom tearsheets"
description: "quantstats 0.0.86 against custom metrics, its HTML report, pyfolio-reloaded and a custom tearsheet whose charts are the figures of this lesson."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/quantstats-reports-and-tearsheets/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/quantstats-reports-and-tearsheets.png"
---

# 2.6.3 · Reports with QuantStats or pyfolio-reloaded and custom tearsheets

> A tearsheet puts a strategy's metrics and charts on one page. quantstats generates one with a single call; this lesson checks what each of its figures computes, where it uses conventions different from those of lesson 2.6.1, and builds a custom tearsheet with pandas and Vega: its four panels are the figures of the lesson.

- Course: [Level 2: Systematic strategies and rigorous backtesting](https://pythonparatrading.com/en/python-trading-course/level-2/)
- Module: 2.6 · Performance metrics
- Duration: 55 min
- Prerequisites: [2.6.1 · CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index](https://pythonparatrading.com/en/python-trading-course/level-2/return-and-risk-metrics/), [0.6.1 · Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts](https://pythonparatrading.com/en/python-trading-course/level-0/matplotlib-seaborn-plotly-charts/)

## By the end of this lesson

- Use `quantstats.stats` and `quantstats.reports` (metrics and html) on a custom return series, with no network and no warnings in the output.
- Identify the quantstats metrics that do not match the definitions of lesson 2.6.1 (CAGR, Ulcer Performance Index, information ratio, daily beta) and why.
- Know when to use pyfolio-reloaded and what installing it with pandas 3 requires.
- Build a custom tearsheet: metrics table, monthly returns, worst drawdowns and rolling Sharpe, with the data ready to plot.
- Read a one-year rolling Sharpe knowing that its standard error is around 1.

## What a tearsheet is

A **tearsheet** (literally, “a sheet to tear off”) is the summary of a strategy on one page: a metrics table and a few
charts that can be read at a glance. The name spread with pyfolio, Quantopian's library, and today almost every
backtesting library has its own. They all have similar pieces:

```mermaid
flowchart LR
  R[Returns] --> M[Metrics table]
  B[Benchmark] --> M
  R --> C[Equity and DD]
  B --> C
  R --> MM[Monthly table]
  R --> P[Worst DDs]
  R --> S[Rolling Sharpe]
```

The metrics are those of lessons 2.6.1 and 2.6.2. The value of a library is the format and the speed; the risk, that
each figure comes with a convention the report does not state. This lesson does both things: it generates the quantstats
report and checks what is behind each number, and then builds a custom one. The strategy is the TSMOM on 24 futures of
lesson 2.6.1, on the level 2 synthetic data (with the momentum effect planted), against the IDA index future as the
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: the individual metrics

quantstats[^1] (Apache 2.0, version 0.0.86 of 27 September 2026 on PyPI) has three modules: `qs.stats` with the
metrics, `qs.plots` with the charts and `qs.reports` with the reports. When plotting, it asks matplotlib for the Arial
font; on a system without it, each figure writes `findfont: Font family 'Arial' not found` to the log, dozens of lines
that clutter the output. The warning is harmless (matplotlib uses another font) and is silenced deterministically by
raising the level of that specific logger, without touching the other warnings:

```python
import logging
# quantstats asks matplotlib for Arial; if it is not installed, each
# figure writes “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
```

The `qs.stats` functions take a return series with a date index. Against those of lesson 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, maximum drawdown and Ulcer Index** match the custom ones to the fourth decimal. The quantstats
  Ulcer divides by $N - 1$ instead of $N$, a difference that is invisible with 5,263 sessions.
- **CAGR and Calmar** differ in the fourth decimal: quantstats counts years as sessions divided by 252 (its own code
  says so), like empyrical. In futures it barely shows; in currencies or crypto, it does (lesson 2.6.1).
- **The “Ulcer Performance Index”** is not the Martin ratio: 163.79 against 1.93. quantstats divides the **cumulative**
  return of the whole sample (1,051%) by the Ulcer Index, and Martin defines the UPI with the annual return minus the
  risk-free rate[^2]. With the cumulative return, the figure grows with the years in the sample and compares nothing.

```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"quantstats IR (daily):    {ir_qs:.4f}")
print(f"Same, times root of 252:  {ir_qs * np.sqrt(252):.4f}")
print(f"Annualized monthly IR:    {ir_mensual:.4f}")
print(f"quantstats beta (daily):  {g['beta']:.3f}")
print(f"Monthly beta:             {beta_m:.3f}")
```
```output
quantstats IR (daily):    0.0031
Same, times root of 252:  0.0492
Annualized monthly IR:    0.0196
quantstats beta (daily):  0.070
Monthly beta:             0.089
```

The quantstats information ratio is the daily mean of the active return divided by its daily standard deviation, not
annualized: 0.0031, which times $\sqrt{252}$ would be 0.049. With monthly data, 0.020. The three figures say the same
thing (TSMOM and the index return similar amounts on average, 12.4% and 11.3% a year annualized over 252 sessions, and the active
return is very volatile, so the IR is close to zero), but in a table next to annual IRs from another source the quantstats
one would look sixteen times smaller. The beta is daily too: 0.070 against 0.089 monthly. Here they are similar, but they
need not be: TSMOM positions take weeks to flip, and the relationship with the index can be different over a month than
over a day. Before comparing a beta, ask its frequency.

## The full report: metrics and html

`qs.reports.metrics` returns the table of a report (81 rows in `full` mode) as a DataFrame, and `qs.reports.html` writes
it to an HTML file together with the charts.

```python
tabla = qs.reports.metrics(r, benchmark=ref, mode="full",
                           display=False)
print(len(tabla), "rows")
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 rows
                        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
```

The figures are those of `qs.stats`, rounded to two decimals and converted to text (`dtype` object): they are for
reading, not for further computation. “Longest DD Days” are calendar days (980 for TSMOM, from 4 August 2015 to 10 April
2018), not sessions. “Payoff Ratio” and “Profit Factor” are computed over days, not over the trades of lesson 2.6.2.
“Smart Sharpe” penalizes the Sharpe for the lag-1 autocorrelation of daily returns (the first exercise compares it with
the Lo correction). One of the table's labels, the CAGR one, carries an uncommon Unicode percent sign (U+FE6A) that is
not in every font: it is worth renaming it before publishing the table.

The HTML report is written to a temporary folder and measured, instead of being printed:

```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 vs IDA")
    html = ruta.read_text(encoding="utf-8")

print(f"{len(html) / 1e6:.1f} MB")
print("SVG figures:", html.count("<svg"),
      "· tables:", html.count("<table"))
print(re.findall(r"<h3>([^<]*)</h3>", html))
```
```output
2.0 MB
SVG figures: 14 · tables: 3
['Key Performance Metrics', 'EOY Returns vs Benchmark', 'Worst 10 Drawdowns']
```

That is 2.0 MB with 14 embedded SVG figures (cumulative return on a normal scale, a log scale and adjusted for
volatility, annual returns, daily returns, distributions, beta, volatility, rolling Sharpe and Sortino, drawdown
periods, underwater chart and monthly heatmap) and three tables: the metrics, each year's return against the benchmark
and the ten worst drawdowns. It needs no network when given custom series; quantstats lists yfinance among its
dependencies for when a ticker is passed as the benchmark.

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

`qs.reports.html` aligns the strategy and the benchmark by date by default (`match_dates=True`). With series on
different calendars (currencies on weekdays against an index on NYSE sessions) it drops the days that do not match.
The metrics of such a report are not those of the full series: always compare with the ones computed on the original
series.

</aside>

## pyfolio-reloaded

pyfolio-reloaded[^3] is the maintained continuation of pyfolio, Quantopian's tearsheet library, and uses
empyrical-reloaded for the metrics. Its strong point is the position and transaction tearsheets: exposure by asset,
turnover, round trips (`round_trips`) and capacity (`create_capacity_tear_sheet`, with volume participation limits like
those of lesson 2.6.2). Its version 0.10.0 (17 October 2026) requires `pandas<3` on Python 3.14 according to its PyPI
metadata, so it is not installed in the course's main environment (pandas 3.0.6). The code, which would run in a
separate environment with pandas 2.x like the Zipline one in lesson 2.4.6:

```python skip
# pyfolio-reloaded 0.10.0 requires pandas < 3 with Python 3.14: it needs
# its own environment (like the Zipline one), not the course's main one.
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` draws in a single matplotlib figure the equity against the benchmark, the drawdowns, the
rolling beta and Sharpe, the monthly and annual returns and the distribution of returns; `show_perf_stats` prints the
metrics table. To use it from the main environment, the practical approach is to save the returns to Parquet and run
the tearsheet in the other environment.

## A custom tearsheet

A custom tearsheet has three advantages over a library's: each figure has the definition that has been decided (and
tested in lesson 2.6.1), it includes the metrics that matter for that strategy and not the eighty of a generic report,
and its output is data that can be plotted with any tool. The one in this lesson is four pandas functions and four Vega
figures.

```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),
            "Volatility": r.std() * np.sqrt(252),
            "Sharpe": sharpe(r), "Sortino": sortino(r),
            "Max DD": drawdown(r).min(),
            "Years underwater (max)": episodios(r)["anos"].max(),
            "Ulcer": ulcer(r),
            "Calmar": cagr_fechas(r) / -drawdown(r).min(),
            "Monthly skewness": m.skew(),
            "Worst month": m.min(), "Positive months": (m > 0).mean()}

pd.DataFrame({"TSMOM": metricas(r), "IDA": metricas(ref)}).round(3)
```
```output
                        TSMOM    IDA
CAGR                    0.124  0.097
Volatility              0.121  0.203
Sharpe                  1.026  0.559
Sortino                 1.527  0.805
Max DD                 -0.194 -0.622
Years underwater (max)  2.683  3.817
Ulcer                   0.064  0.178
Calmar                  0.639  0.156
Monthly skewness        0.156 -2.151
Worst month            -0.082 -0.483
Positive months         0.598  0.641
```

The table summarizes what matters about TSMOM against its benchmark: more CAGR (12.4% against 9.7%) with three fifths
of the volatility, a Sharpe of 1.03 against 0.56, a maximum drawdown of −19.4% against −62.2% and fewer years underwater
in the worst episode (2.7 against 3.8). Monthly skewness sets them apart: 0.16 for TSMOM and −2.15 for the index, whose
worst month is −48.3% (March 2020). TSMOM's worst month is −8.2% (August 2015), and 59.8% of its months are positive,
fewer than for the index (64.1%): TSMOM wins fewer months and loses less in the bad ones. The header figure is its
equity and drawdown panel.

```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. — Own tearsheet, panel 2: TSMOM monthly returns from 2019 to 2025 (blue, positive months; orange, negative; more intense, beyond 2% and 5%) and the full-year return, in %.* Colour table with one year per row, from 2019 to 2025, and one month per column, plus a column with the year's return. 51 of the 84 months are positive. The worst month, highlighted, is March 2020, at −5.9%. The best year is 2023, at +31%; the worst, 2025, at −4%.

The monthly table is the most looked-at piece of a tearsheet and the most misleading: the eye looks for calendar
patterns where there is only noise. In this data, 2023 adds up to +31.1% and 2025 to −4.3%; the worst month of the
seven years in the figure is March 2020 (−5.9%). None of those figures says anything about 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. — Own tearsheet, panel 4: TSMOM's five worst drawdowns, with their depth, the years they span and their time under water.* Five horizontal bars pointing left. The worst, highlighted: −19.4%, from 2015 to 2018, 2.68 years under water. Then −16.1% (2007-2008, 1.25 years), −12.4% (2012-2013, 0.46 years), −12.1% (2025, 0.36 years) and −12.0% (2006-2007, 0.69 years). All five are closed by the end of 2025.

The five worst drawdowns sorted by depth, with their duration: the first (−19.4%, from August 2015 to April 2018) lasts
2.7 years; the second (−16.1%, from 2007 to 2008), 1.2; the other three, between −12.4% and −12.0%, recover in less than
a year. All five are closed, but right behind them there is an open one: since 26 June 2025, TSMOM is 11.8% below its
high, and 31 December is its low. A tearsheet has to tell a closed episode from an ongoing one, whose depth and duration
can only grow.

```python
sm = (r.rolling(252).mean() / r.rolling(252).std() * np.sqrt(252))
sm = sm.dropna()
print(f"Full-sample Sharpe: {sharpe(r):.2f}")
print(f"One-year Sharpe: from {sm.min():.2f} to {sm.max():.2f}")
print(f"Windows with negative Sharpe: {(sm < 0).mean():.1%}")
print(f"Standard deviation of the one-year Sharpe: {sm.std():.2f}")
```
```output
Full-sample Sharpe: 1.03
One-year Sharpe: from -1.52 to 3.24
Windows with negative Sharpe: 12.3%
Standard deviation of the one-year Sharpe: 0.91
```

*Fig. — Own tearsheet, panel 3: TSMOM one-year rolling Sharpe against the full-sample Sharpe, with the ± 2 standard error band of a one-year window.* Line of TSMOM's Sharpe over the last 252 sessions, week by week from 2006 to 2025, swinging between −1.5 and 3.2. A highlighted line marks the full-sample Sharpe, 1.03, and a grey band of 1.03 ± 2 marks what chance alone produces with one year of data if the true Sharpe were that: the line leaves it in only 1% of windows. 12.3% of windows have a negative Sharpe.

The Sharpe of the last 252 sessions ranges from −1.52 to 3.24 and is negative in 12.3% of the windows, with a
full-sample Sharpe of 1.03 and an effect planted in the data. It is not that the strategy “stops working” one year in
eight: with 252 sessions the standard error of an annual Sharpe is around 1 (lesson 2.6.1), and the standard deviation
of the one-year Sharpe of TSMOM, 0.91, is of that order. The gray band in the figure, the sample Sharpe ± 2, is what
chance can be expected to produce; the line only leaves it in 1% of the windows. A rolling Sharpe without that band
invites you to see regimes where there is noise.

```mermaid
flowchart TB
  F[pandas functions] --> T[Tables]
  T --> J[JSON data]
  J --> V[Vega-Lite spec]
  V --> SVG[SVG at build time]
```

```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 of data")
```
```output
{'capital': 1092, 'drawdown': 1092, 'mensual': 272, 'sharpe_movil': 1040}
84 KB of data
```

The last cell leaves the data ready to plot: weekly equity and drawdown, the monthly table in long format and the
rolling Sharpe. The figures of this lesson were made this way: that data, downsampled so that each SVG weighs less than
12 KB, goes inside a Vega-Lite spec that the site compiles to SVG at build time (which is why the figure's monthly table
stops at seven years). With matplotlib or plotly (lesson 0.6.1) the final step would be different, but the separation is
the same: first the figures, checked; then the drawing.

## What a good tearsheet should have

- **The period, the frequency and the conventions** in plain sight: sessions per year, risk-free rate subtracted or
  not, benchmark.
- **The metrics for the strategy's shape**, not only those of dispersion: monthly skewness, worst months, drawdown
  duration. For a carry, the left tail; for a trend strategy, the flat stretches.
- **The error of each estimate**: the Sharpe with its interval, the rolling Sharpe with its band.
- **Costs**: gross and net, with the turnover that generates them (lesson 2.6.2).
- **The separation between in-sample and out-of-sample**, if there is one (lesson 2.5.2): a tearsheet that mixes the
  two periods without marking them is a sales brochure.
- **No figure without its definition**. If it comes from a library, the library version.

## Exercises

1. Compare the quantstats “Smart Sharpe” with the annualized Sharpe with the Lo (2002) correction of lesson 2.6.1.
   Which autocorrelation does each one use?

**Solution.**

```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"lag-1 daily autocorrelation: {r.autocorr(1):.3f}")
print(f"lag-1 monthly autocorrelation: {rho[0]:.3f}")
print(f"Sharpe {qs.stats.sharpe(r):.3f} · "
      f"smart {qs.stats.smart_sharpe(r):.3f}")
print(f"Lo with monthly data: {mm.mean() / mm.std() * eta:.3f}")
```
```output
lag-1 daily autocorrelation: 0.009
lag-1 monthly autocorrelation: -0.048
Sharpe 1.026 · smart 1.016
Lo with monthly data: 1.109
```

Smart Sharpe uses a single autocorrelation, the lag-1 one of daily returns (0.009), and assumes the others decay as its
powers: it lowers the Sharpe from 1.026 to 1.016. Lo uses the monthly autocorrelations at lags 1 to 11; the lag-1 one is
−0.048 and the weighted sum of all of them is negative, so the factor $\eta(12)$ ends up at 3.717, above $\sqrt{12}$:
the monthly Sharpe goes from 1.033 to 1.109. The two corrections go in opposite directions because of a design
difference: Smart Sharpe takes the absolute value of the autocorrelation, so it penalizes any autocorrelation, negative
included; in Lo's formula, a negative autocorrelation raises the annual Sharpe.

2. Compute the rolling Sharpe with 63-session windows (one quarter). In what fraction of the windows is it negative and
   what is its standard deviation?

**Solution.**

```python
s63 = (r.rolling(63).mean() / r.rolling(63).std()
       * np.sqrt(252)).dropna()
print(f"Negative windows: {(s63 < 0).mean():.1%}")
print(f"Standard deviation: {s63.std():.2f} "
      f"(expected, about {np.sqrt(252 / 63):.1f})")
```
```output
Negative windows: 29.1%
Standard deviation: 1.96 (expected, about 2.0)
```

With one quarter, the annualized Sharpe is negative in 29.1% of the windows and its standard deviation is 1.96, almost
exactly the 2 of the formula ($\sqrt{252/63}$: the standard error of an annual Sharpe with a quarter of a year of
data). With one quarter of data, a strategy with a Sharpe of 1 and a planted effect has one negative quarter in every
three or four. That is why a quarterly report does not say whether a strategy works.

3. Design the tearsheet for the currency carry of lesson 2.6.1. What would you change compared with the TSMOM one?

**Solution.**

The annualization: 261 weekdays a year, not 252, and the CAGR by dates (quantstats and empyrical understate it by 0.14%
a year on that series). The benchmark: an equity index is not the natural reference for a carry; better the rate
differential itself or nothing, and if it is compared with IDA, watch out for `match_dates`, because the calendars do
not match. The shape metrics, up front: monthly skewness and kurtosis, the five worst months with their dates (the
crashes), the Calmar and the duration of the worst drawdown (9.8 years). The Sharpe with its standard error corrected for
skewness (0.239 against 0.219) and for autocorrelation (0.35 instead of 0.41). And a panel TSMOM does not need: the
cumulative return in the crisis periods against the rest, which is where it is decided whether a strategy with negative
skewness can be traded.

[^1]: Ran Aroussi, QuantStats, <https://github.com/ranaroussi/quantstats>; version, date and dependencies at
    <https://pypi.org/project/quantstats/0.0.86/>.
[^2]: Peter G. Martin, “Ulcer Index”, <https://www.tangotools.com/ui/ui.htm>: UPI = (annualized total return −
    risk-free return) / UI.
[^3]: Stefan Jansen, pyfolio-reloaded, <https://github.com/stefan-jansen/pyfolio-reloaded>; requirements by Python
    version at <https://pypi.org/project/pyfolio-reloaded/>.

## Self-assessment

1. `qs.stats.ulcer_performance_index` gives 163.79 for TSMOM and the Martin ratio of lesson 2.6.1 gives 1.93. Why?

   - A. Because quantstats computes the Ulcer Index incorrectly
   - B. Because one of them uses log returns
   - C. Because quantstats annualizes the Ulcer Index
   - D. Because quantstats divides the cumulative return of the whole sample (1,051%) by the Ulcer Index, and Martin divides the annual return minus the risk-free rate

   *Answer D: Because quantstats divides the cumulative return of the whole sample (1,051%) by the Ulcer Index, and Martin divides the annual return minus the risk-free rate.* Both Ulcer Indexes match (0.0642). The numerator does not: with the cumulative return, the quantstats “UPI” grows with the length of the sample and cannot be compared across periods. It is a difference of definition that only shows up when reading the code.

2. `qs.stats.information_ratio(r, ref)` returns 0.0031. What is that figure?

   - A. The annual information ratio
   - B. The correlation with the benchmark
   - C. The daily mean of the active return divided by its daily standard deviation, not annualized: times √252 it would be 0.049
   - D. The tracking error

   *Answer C: The daily mean of the active return divided by its daily standard deviation, not annualized: times √252 it would be 0.049.* quantstats does not annualize the IR. Compared with an annual IR from another source, it looks 16 times smaller. Something similar happens with beta: the quantstats one is daily (0.070) and the monthly one is 0.089.

3. Why does the lesson not run pyfolio-reloaded?

   - A. Because it is abandoned
   - B. Because its version 0.10.0 requires pandas < 3 on Python 3.14, and the course's main environment has pandas 3.0.6: it needs a separate environment
   - C. Because it only works with Zipline
   - D. Because it needs an internet connection

   *Answer B: Because its version 0.10.0 requires pandas < 3 on Python 3.14, and the course's main environment has pandas 3.0.6: it needs a separate environment.* Its PyPI metadata says so. It works the same way as the course's Zipline environment: another uv environment with pandas 2.x, to which the returns are passed as a file.

4. The one-year Sharpe of TSMOM ranges from −1.5 to 3.2 even though the full-sample Sharpe is 1.03. Which conclusion is correct?

   - A. That with 252 sessions the standard error of the annual Sharpe is around 1, so those swings fall almost entirely within what chance produces
   - B. That the strategy changes regime every year
   - C. That you should only trade when the rolling Sharpe is positive
   - D. That the full-sample Sharpe is wrong

   *Answer A: That with 252 sessions the standard error of the annual Sharpe is around 1, so those swings fall almost entirely within what chance produces.* The standard error of the annual Sharpe is about 1/√years (lesson 2.6.1): with one year, 1. The standard deviation of the one-year Sharpe of TSMOM is 0.91, of the same order. A rolling Sharpe without its band invites you to see regimes where there is noise.

5. What should a custom tearsheet for a carry strategy add that the quantstats one does not include?

   - A. Nothing: the quantstats one is complete
   - B. The shape and tail metrics that matter for a strategy with negative skewness (monthly skewness, worst months, drawdown duration), the standard error of the Sharpe and the annualization convention of its market
   - C. More colors
   - D. Only the CAGR

   *Answer B: The shape and tail metrics that matter for a strategy with negative skewness (monthly skewness, worst months, drawdown duration), the standard error of the Sharpe and the annualization convention of its market.* A generic tearsheet gives every strategy the same figures. A custom one shows the ones that decide whether that particular strategy gets traded: for carry, the left tail; for TSMOM, the duration of flat stretches.

## Key points

- quantstats 0.0.86 computes Sharpe, Sortino, maximum drawdown and Ulcer Index the same way as the custom functions; its CAGR counts years as sessions divided by 252.
- Its “Ulcer Performance Index” uses the cumulative return of the whole sample, not the annual one: it is not the Martin ratio. Its information ratio and beta are daily and not annualized.
- `qs.reports.html` writes a 2.0 MB report with 14 SVG figures and three tables, with no network; the `findfont` warnings are silenced with the `matplotlib.font_manager` logger.
- pyfolio-reloaded 0.10.0 requires pandas < 3 with Python 3.14: it is used in a separate environment.
- A custom tearsheet is a few pandas functions whose outputs get plotted: metrics, equity and drawdown, monthly table, worst drawdowns and rolling Sharpe with its error band.
- A one-year Sharpe has a standard error of around 1: without the band, a rolling Sharpe is misleading.

## Resources

### Research

- [Zipline in Python - Part 1: Quant Stack and Pipeline](https://pythonparatrading.com/en/zipline-backtesting-with-python-quant-stack/) · Research-0011 · 11 min

### Documentation

- [QuantStats: source code and documentation (Apache 2.0)](https://github.com/ranaroussi/quantstats) · github.com
- [quantstats 0.0.86 on PyPI (metadata and dependencies)](https://pypi.org/project/quantstats/0.0.86/) · pypi.org
- [pyfolio-reloaded: source code (Apache 2.0)](https://github.com/stefan-jansen/pyfolio-reloaded) · github.com
- [pyfolio-reloaded on PyPI (requirements by Python version)](https://pypi.org/project/pyfolio-reloaded/) · pypi.org
- [Vega-Lite: documentation](https://vega.github.io/vega-lite/docs/) · vega.github.io

### Data

- [Level 2 data: continuous futures, 2000-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_continuos.parquet) · Download · futuros2_continuos.parquet

Previous lesson: [2.6.2 · Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity](https://pythonparatrading.com/en/python-trading-course/level-2/trade-metrics-alpha-beta-capacity/)
