Lesson 2.6.3 · 55 min

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.

Fig. — Own tearsheet, panel 1: TSMOM wealth against the IDA index future (log scale) and TSMOM drawdown, February 2005 to 2025
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

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:

ReturnsMetrics tableBenchmarkEquity and DDMonthly tableWorst DDsRolling Sharpe
Mermaid source
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.

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 and the IDA index
5263 2005-02-01 2025-12-31

quantstats: the individual metrics

quantstats1Note 1 · Ran Aroussi, QuantStats, https://github.com/ranaroussi/quantstats; version, date and dependencies at https://pypi.org/project/quantstats/0.0.86/. (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:

IN [02]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__)
OUT [02]quantstats without font warnings
0.0.86

The qs.stats functions take a return series with a date index. Against those of lesson 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]The metrics of lesson 2.6.1 against 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, maximum drawdown and Ulcer Index match the custom ones to the fourth decimal. The quantstats Ulcer divides by N−1N - 1 instead of NN, 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 rate2Note 2 · Peter G. Martin, “Ulcer Index”, https://www.tangotools.com/ui/ui.htm: UPI = (annualized total return − risk-free return) / UI.. With the cumulative return, the figure grows with the years in the sample and compares nothing.
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"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}")
OUT [04]Information ratio and beta: daily and not annualized
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 252\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.

IN [05]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"]]
OUT [05]The qs.reports.metrics table
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:

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 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))
OUT [06]The HTML report, to a temporary file
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.

pyfolio-reloaded

pyfolio-reloaded3Note 3 · Stefan Jansen, pyfolio-reloaded, https://github.com/stefan-jansen/pyfolio-reloaded; requirements by Python version at https://pypi.org/project/pyfolio-reloaded/. 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:

IN [07]python
# 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.

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),
            "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)
OUT [08]Custom tearsheet: metrics table
                        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.

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]Monthly and annual returns
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 %

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.

IN [10]python
episodios(r).sort_values("profundidad").head(5).round(3)
OUT [10]Worst 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. — Own tearsheet, panel 4: TSMOM's five worst drawdowns, with their depth, the years they span and their time under water

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.

IN [11]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}")
OUT [11]One-year rolling Sharpe
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

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.

pandas functionsTablesJSON dataVega-Lite specSVG at build time
Mermaid source
flowchart TB
  F[pandas functions] --> T[Tables]
  T --> J[JSON data]
  J --> V[Vega-Lite spec]
  V --> SVG[SVG at build time]
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 of data")
OUT [12]From tables to figures
{'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
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"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}")
OUT [13]Smart Sharpe against Lo
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 η(12)\eta(12) ends up at 3.717, above 12\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.

  1. 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
IN [14]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})")
OUT [14]One-quarter rolling Sharpe
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 (252/63\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.

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

Footnotes

  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/. ↩

Each answer is folded under its question

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?

    • ABecause quantstats computes the Ulcer Index incorrectly
    • BBecause one of them uses log returns
    • CBecause quantstats annualizes the Ulcer Index
    • DBecause 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
    Show answer

    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?

    • AThe annual information ratio
    • BThe correlation with the benchmark
    • CThe daily mean of the active return divided by its daily standard deviation, not annualized: times √252 it would be 0.049
    • DThe tracking error
    Show answer

    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?

    • ABecause it is abandoned
    • BBecause 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
    • CBecause it only works with Zipline
    • DBecause it needs an internet connection
    Show answer

    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?

    • AThat with 252 sessions the standard error of the annual Sharpe is around 1, so those swings fall almost entirely within what chance produces
    • BThat the strategy changes regime every year
    • CThat you should only trade when the rolling Sharpe is positive
    • DThat the full-sample Sharpe is wrong
    Show answer

    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?

    • ANothing: the quantstats one is complete
    • BThe 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
    • CMore colors
    • DOnly the CAGR
    Show answer

    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

Data