QuantStats Tutorial: Build and Audit a Backtest Report
Learn how to use QuantStats in Python to generate HTML tear sheets from backtest returns, reconcile metrics against raw equity ledgers, and audit edge cases.
Conceptual illustration: a portfolio report is reconciled against its source return and transaction ledger.
While these headline numbers offer a quick snapshot, relying strictly on scalar metrics introduces substantial confirmation bias. A backtest showing a 1.72 Sharpe ratio can easily mask prolonged underwater stagnation, severe tail risk, extreme return skewness, or heavy dependency on a handful of anomalous market sessions.
To evaluate whether a trading strategy possesses a genuine statistical edge, quantitative researchers generate comprehensive performance profiles known as tear sheets. Originally developed by institutional asset managers to present standardized fund performance to allocators, tear sheets combine high-density statistical tables with diagnostic visual curves: underwater drawdown charts, rolling volatility, monthly return heatmaps, and benchmark factor correlations.
Code
flowchart TD subgraph BacktestEngine ["Backtest Simulation Engine"] Bars["Historical OHLCV Bars"] Logic["Signal & Risk Logic"] Ledger["Order & Cash Ledger (Bar-by-Bar NAV)"] Bars --> Logic Logic --> Ledger end subgraph DataExtraction ["Extraction & Sanitization"] RawNAV["Portfolio Net Asset Value (NAV)"] PctReturn["Sanitized Periodic Return Series (pd.Series)"] Ledger --> RawNAV RawNAV --> PctReturn end subgraph QuantStatsEngine ["QuantStats Reporting Engine"] Stats["qs.stats (Sharpe, Sortino, VaR, Calmar)"] Plots["qs.plots (Drawdown, Rolling Beta, Heatmap)"] Reports["qs.reports (Metrics, Basic, Full HTML)"] PctReturn --> Stats PctReturn --> Plots Stats --> Reports Plots --> Reports end subgraph Distribution ["Audit & Deliverables"] HTML["Standalone Interactive HTML Tear Sheet"] Console["High-Density Metrics Table"] AuditCheck["Reconciliation Against Raw Cash Ledger"] Reports --> HTML Reports --> Console Reports --> AuditCheck end
In the Python quantitative ecosystem, QuantStats (quantstats python) created by Ran Aroussi has become a widely adopted open-source library for automated tear sheet generation. Built on top of pandas, numpy, scipy, and matplotlib/seaborn, QuantStats calculates more than 60 standard portfolio metrics and compiles them into standalone, publication-ready HTML reports.
However, QuantStats is strictly a post-run mathematical reporting library—it does not execute trades, model order books, or simulate execution friction. Because it accepts an isolated return series as its primary input, researchers must verify that the underlying returns series faithfully reflects real-world accounting mechanics. Without rigorous data sanitization, compounding calibration, and calendar alignment, a generated quantstats report can look visually compelling while displaying completely distorted performance metrics.
QuantStats is architected into three distinct layers:
quantstats.stats: A collection of vectorized mathematical functions that calculate individual performance, risk, and drawdown statistics (such as qs.stats.cagr, qs.stats.sharpe, qs.stats.max_drawdown, qs.stats.value_at_risk, and qs.stats.tail_ratio).
quantstats.plots: Matplotlib and Seaborn visualization wrappers that render specific analytical charts (such as qs.plots.drawdowns_periods, qs.plots.monthly_heatmap, qs.plots.rolling_beta, and qs.plots.returns).
quantstats.reports: High-level report compilers that orchestrate statistics and plots into consolidated outputs (qs.reports.metrics for terminal tables, qs.reports.basic for lightweight plots, and qs.reports.html for complete interactive HTML dossiers).
QuantStats requires a strict data format. Failing to satisfy this contract leads to silent mathematical errors, distorted annualization, or runtime crashes:
Data structure: A 1-dimensional pandas.Series. DataFrames with multiple columns will raise calculation errors or render blank plots unless flattened.
Index type: A strictly monotonic, sorted pandas.DatetimeIndex. Integer or string indexes will fail time-series operations such as monthly rolling aggregation and CAGR compounding.
Value representation: Periodic fractional decimal returns, defined as:
Code
r_t = (NAV_t - NAV_{t-1}) / NAV_{t-1}
A daily return of +1.5% must be represented as 0.015, not 1.5 and not the raw portfolio balance $101,500.00.
Zeroes vs. NaNs: Periods where the portfolio was 100% in cash and experienced zero return must be explicitly recorded as 0.0, never dropped and never set to NaN. Dropping non-trading days artificially compresses the calendar, corrupting standard deviation and volatility metrics.
Timezone consistency: Datetime indexes must be timezone-naive or consistently normalized to UTC across both the strategy series and any benchmark series.
Contract Parameter
Valid QuantStats Specification
Common Implementation Error
Analytical Consequence
Data Type
pandas.Series
pandas.DataFrame (multi-column)
Method errors or corrupted multi-asset aggregation
Index
Sorted pd.DatetimeIndex
Integer range or unordered dates
CAGR, drawdown duration, and monthly heatmaps fail
Value Scale
Fractional decimals (0.012 for +1.2%)
Percentage integers (1.2) or raw equity
Volatility and Sharpe ratio inflated by orders of magnitude
Inactive Days
Explicit 0.0 returns
np.nan or omitted calendar rows
Calendar compression; volatility and Sharpe severely distorted
Benchmark Index
Identical date bounds and frequency
Mismatched trading holidays or unaligned hours
Silent inner-join truncations and distorted Alpha/Beta
To demonstrate generating a tear sheet, consider a trading strategy that exports its portfolio Net Asset Value (NAV) ledger over a multi-year backtest. The following script prepares the return series, configures a market benchmark, and generates both a terminal summary and an interactive HTML report.
QuantStats can be installed via standard package managers:
Code
pip install quantstats
[!NOTE]
If you are running Python 3.12 or newer with Pandas 2.x, some legacy releases of QuantStats trigger deprecation warnings or compatibility issues with pandas.Series.fillna(method="ffill"). In modern production virtual environments, researchers often utilize updated community distributions such as quantstats-lumi or apply compatibility patches.
When executing the script, QuantStats outputs a clean statistical comparison between the strategy and the designated benchmark:
Code
--- COMPUTING QUANTSTATS SUMMARY METRICS --- Benchmark (SPY) Strategy (Trend_Strategy)------------------ ----------------- ---------------------------Start Period 2022-01-04 2022-01-04End Period 2025-12-31 2025-12-31Risk-Free Rate 0.0% 0.0%Time in Market 100.0% 100.0%Cumulative Return 38.42% 58.91%CAGR% 8.47% 12.28%Sharpe 0.55 0.76Prob. Sharpe Ratio 85.22% 93.18%Sortino 0.77 1.08Sortino/√2 0.54 0.76Omega 1.09 1.14Max Drawdown -18.42% -15.11%Longest DD Days 342 284Gain/Loss Ratio 1.12 1.18Profit Factor 1.09 1.14Daily Value-at-Risk -1.61% -1.78%Expected Shortfall -2.02% -2.24%Calmar 0.46 0.81Skew -0.08 0.02Kurtosis 0.14 0.19[SUCCESS] Standalone HTML tear sheet exported to: quantstats_strategy_report.html
The accompanying quantstats_strategy_report.html file compiles interactive visualizations: cumulative return comparisons, rolling Sharpe ratio (typically 6-month and 12-month windows), monthly return heatmaps (matrix of year vs. month), and drawdown recovery periods.
While generating a report takes only a few lines of Python, accepting its calculations uncritically is dangerous. A quantitative researcher must perform four essential reconciliation audits between the raw simulation ledger and the tear sheet.
QuantStats relies on the parameter periods_per_year to convert periodic daily standard deviation and returns into annualized figures. By default, QuantStats sets periods_per_year=252, matching conventional US equity market trading sessions.
Annualized volatility and Sharpe ratios scale with the square root of time:
If your trading strategy operates in 24/7 continuous markets (such as cryptocurrency or decentralized liquidity protocols), using the default value of 252 introduces a massive mathematical error:
A frequent source of discrepancy between backtest engines and QuantStats is the difference between cumulative compound returns and arithmetic return sums.
The true cumulative portfolio return across $T$ periods must be calculated via geometric product:
Code
R_cumulative = prod_{t=1}^{T} (1 + r_t) - 1
Reconciling ending capital against initial capital must satisfy:
Code
Ending Capital = Initial Capital * (1 + R_cumulative)
Never audit returns by summing daily percentage returns sum(r_t). Arithmetic summation ignores compounding drag, especially during volatile drawdown periods:
A portfolio that experiences -10% on Day 1 and +10% on Day 2 has an arithmetic sum of 0.0%.
In actual equity accounting, 1.0 * (1 - 0.10) * (1 + 0.10) = 0.99, representing a -1.0% net loss.
Over hundreds of trading bars, the arithmetic sum diverges aggressively from the true Net Asset Value. Always verify that QuantStats' reported Cumulative Return matches the exact cash-and-position balance in your simulation database.
When a strategy holds 100% cash, should those days be included in the return series? The answer is an unequivocal yes.
If a strategy trades only 40 days across a 252-day year, and you filter out the 212 inactive days from the Series, QuantStats evaluates the series as if those 40 trading days occurred consecutively over an 8-week period. This triggers two catastrophic errors:
Volatility distortion: Standard deviation is calculated across high-action trading days only, ignoring the stabilizing zero-volatility cash periods.
CAGR distortion: QuantStats calculates Compound Annual Growth Rate based on the calendar duration between the first and last timestamps in the series:
An inner join silently drops any date present in your strategy that is not present in the benchmark (and vice-versa). If your backtest includes foreign market holidays or Saturday bars, those bars are excised from the report without warning. Always align strategy and benchmark series against a complete master trading calendar before passing them to qs.reports.
By default, QuantStats sets rf = 0.0. During historical eras of near-zero interest rates, this assumption introduced minimal distortion. However, in regimes where 3-month US Treasury bills yield between 4.0% and 5.5%, assuming rf = 0.0 inflates risk-adjusted performance metrics:
Code
Sharpe Ratio (Zero Rf) = Mean(R_p) / Std(R_p)Sharpe Ratio (True Rf) = (Mean(R_p) - Rf_daily) / Std(R_p)
If a strategy delivers an 8.0% annualized return with 10.0% annualized volatility when the risk-free rate is 5.0%:
Under rf = 0.0: Sharpe is 8.0 / 10.0 = 0.80.
Under rf = 0.05: Sharpe is (8.0 - 5.0) / 10.0 = 0.30.
Failing to calibrate rf causes a strategy that barely outpaces cash to masquerade as an attractive algorithmic hedge fund product.
Deploying QuantStats within modern Python data pipelines frequently encounters legacy edge cases. Below are practical solutions to the four most common failure modes.
Historically, calling qs.reports.html(returns, "SPY") instructed QuantStats to fetch benchmark data dynamically via yfinance. Recent updates to Yahoo Finance APIs frequently return MultiIndex column headers (Close, Adj Close) or trigger rate-limit HTTP 429 errors, raising:
Code
KeyError: 'Close'# orTypeError: 'float' object cannot be interpreted as an integer
Remediation: Never allow QuantStats to download benchmarks automatically over the network. Always download, clean, and pass a pre-computed pd.Series directly into the function:
Code
# Anti-pattern: Relies on unversioned external scraper# qs.reports.html(returns, benchmark="SPY")# Best practice: Supply an explicit, pre-sanitized benchmark Seriesbenchmark_df = pd.read_parquet("market_data/spy_daily.parquet")benchmark_returns = benchmark_df["close"].pct_change().dropna()qs.reports.html( returns=strategy_returns, benchmark=benchmark_returns, output="audited_report.html")
Older releases of QuantStats (such as 0.0.59 through 0.0.62) rely on deprecated Pandas methods that were removed in Pandas 2.0.0, such as Series.iteritems() and Series.fillna(method='ffill').
When running in modern Python environments (Python >=3.12), this manifests as:
Code
AttributeError: 'Series' object has no attribute 'iteritems'# orFutureWarning: 'method' is deprecated and will be removed in a future version.
Remediation: In production research repositories, install active community forks such as quantstats-lumi or wrap imports with an explicit monkey-patch before executing reports:
Code
# Monkey-patching compatibility if pinned to legacy quantstatsimport pandas as pdif not hasattr(pd.Series, "iteritems"): pd.Series.iteritems = pd.Series.items
When running backtest reports on headless Linux CI/CD runners or Docker containers without an active X11 display server, QuantStats may crash while rendering plots:
Code
UserWarning: Matplotlib is currently using agg, which is a non-GUI backend, so cannot show the figure.# or_tkinter.TclError: no display name and no $DISPLAY environment variable
Remediation: Explicitly set the Matplotlib backend to non-interactive Agg prior to importing QuantStats:
Code
import matplotlibmatplotlib.use("Agg") # Force headless renderingimport quantstats as qs
If your strategy timestamps are recorded in UTC (2024-01-02 00:00:00+00:00) while the benchmark data has tz-naive timestamps (2024-01-02 00:00:00), Pandas throws an exception during QuantStats alignment:
Code
TypeError: Cannot join tz-naive with tz-aware DatetimeIndex
Remediation: Normalize both indexes to tz-naive UTC calendar dates before computing returns:
Code
# Strip timezones and normalize to pure midnight datesstrategy_returns.index = strategy_returns.index.tz_localize(None).normalize()benchmark_returns.index = benchmark_returns.index.tz_localize(None).normalize()
To build an efficient quantitative infrastructure, developers must understand the operational boundary between a backtest execution engine and a post-hoc statistical reporter.
Code
flowchart TD subgraph ExecutionLayer ["Simulation & Execution Layer (OpenBacktesting)"] Engine["OpenBacktesting Engine (0.2.1)"] Match["Deterministic Bar-by-Bar Order Matching"] Friction["Commissions & Spread Slippage Models"] DB[("Local SQLite Trial Persistence")] Engine --> Match Match --> Friction Friction --> DB end subgraph InspectionLayer ["Interactive Visual Inspection (Studio)"] Studio["OpenBacktesting Studio Dashboard"] Candles["Candlestick Visualizer with Entry/Exit Markers"] Orders["Ticket-Level Order & Fill Inspection"] Sweep["Local Parameter Sweep Heatmaps"] DB --> Studio Studio --> Candles Studio --> Orders Studio --> Sweep end subgraph PostHocLayer ["External Tear Sheet Generation (QuantStats)"] Export["Export Daily Return Series (CSV / Parquet)"] QS["QuantStats (Third-Party Reporting Library)"] HTML["Standalone Static HTML Fact Sheet"] DB -.->|"User Script Export"| Export Export --> QS QS --> HTML end
OpenBacktesting Engine (0.2.1): Designed for single-asset historical OHLCV bar-by-bar research running locally under Python >=3.12, <3.15. It is responsible for execution causality: matching market and limit orders, preventing lookahead leaks, deducting transaction friction, and writing deterministic trial results to a local SQLite database.
Backtesting Dashboard (OpenBacktesting Studio): The local, browser-based graphical interface for interactive research. Studio allows developers to inspect candlestick charts with precise order execution markers, examine fill states, audit cash vs. position allocations, and visualize parameter sweeps across local runs with zero cloud telemetry.
QuantStats: An independent, third-party portfolio analytics tool. It has no awareness of trade execution, order slippage, or bar mechanics. Its sole purpose is calculating cross-sectional portfolio statistics on an already completed return vector and producing a standardized static HTML document.
[!IMPORTANT]
QuantStats is an external post-processing library, not an integrated component of OpenBacktesting Studio. OpenBacktesting Studio provides deep, interactive, bar-level inspection of your simulated trades and orders. If you need a static, shareable HTML tear sheet for external stakeholders or compliance records, you can export your daily return series from OpenBacktesting into a standalone Python script that executes QuantStats.
Before presenting a QuantStats tear sheet to investment committees, allocators, or peer researchers, audit the report against this five-point technical verification checklist:
Verification Category
Audit Procedure
Warning Signs & Failure Signals
Remediation Action
1. Input Sanitization
Verify series is a 1D pd.Series with monotonic pd.DatetimeIndex
Generating a comprehensive tear sheet bridges the divide between low-level simulation logs and institutional risk evaluation. By distilling thousands of simulated bars into standardized statistical metrics, QuantStats provides essential visibility into drawdown depth, recovery duration, rolling volatility, and return skewness.
However, a tear sheet is only as reliable as the return series fed into it. By enforcing strict input contracts, matching annualization assumptions to actual market schedules, accounting for cash drag on non-trading days, and reconciling cumulative returns against raw equity ledgers, quantitative developers can eliminate statistical artifacts and build truly defensible backtest reports.