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.
- Module
- 2.6 · Performance metrics
- Duration
- 55 min
- Prerequisites
- 2.6.1 · CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index, 0.6.1 · Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts
- Author
- Jesús Cuesta
- Updated
Level 2 lessons
2.1 · Statistics and econometrics for trading
- 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
- 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
- 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
- 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
- 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
- 2.1.6 Kalman filter for dynamic hedge ratios
- 2.1.7 Regime detection: hidden Markov models and structural breaks
- 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
2.2 · Anatomy of a trading system
2.3 · Strategy families
- 2.3.1 Trend following and momentum
- 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
- 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
- 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
- 2.3.5 Factors (value, quality, low volatility, size) and event strategies
- 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
- 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
2.4 · Backtesting: engines and paradigms
- 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
- 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
- 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
- 2.4.4 backtesting.py and backtrader: two teaching engines and their status
- 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
- 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
- 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
- 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
2.5 · Biases and validation
- 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
- 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
- 2.5.3 Combinatorial purged cross-validation for strategies
- 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
- 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
- 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
- 2.5.8 “Honest backtest” checklist before moving to paper trading
2.6 · Performance metrics
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 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.
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())5263 2005-02-01 2025-12-31quantstats: 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:
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__)0.0.86The qs.stats functions take a return series with a date index. Against those of lesson 2.6.1:
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) 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 instead of , 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.
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}")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.089The quantstats information ratio is the daily mean of the active return divided by its daily standard deviation, not annualized: 0.0031, which times 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.
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"]]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.12The 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:
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))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:
# 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.
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) 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.641The 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.
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)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.3The 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.
episodios(r).sort_values("profundidad").head(5).round(3) 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.693The 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.
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}")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.91The 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 source
flowchart TB F[pandas functions] --> T[Tables] T --> J[JSON data] J --> V[Vega-Lite spec] V --> SVG[SVG at build time]
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"){'capital': 1092, 'drawdown': 1092, 'mensual': 272, 'sharpe_movil': 1040}
84 KB of dataThe 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
- 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
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}")lag-1 daily autocorrelation: 0.009
lag-1 monthly autocorrelation: -0.048
Sharpe 1.026 · smart 1.016
Lo with monthly data: 1.109Smart 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 ends up at 3.717, above : 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.
- 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
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})")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 (: 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.
- 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
-
Ran Aroussi, QuantStats, https://github.com/ranaroussi/quantstats; version, date and dependencies at https://pypi.org/project/quantstats/0.0.86/. ↩
-
Peter G. Martin, “Ulcer Index”, https://www.tangotools.com/ui/ui.htm: UPI = (annualized total return − risk-free return) / UI. ↩
-
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
qs.stats.ulcer_performance_indexgives 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.
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.
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.
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.
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.htmlwrites a 2.0 MB report with 14 SVG figures and three tables, with no network; thefindfontwarnings are silenced with thematplotlib.font_managerlogger.- 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.