Beyond console prints: The role of portfolio tear sheets in quantitative research

Quantitative strategy research frequently concludes with a terminal readout:

Code
======================= SIMULATION RESULT =======================
Initial Capital:         $100,000.00
Ending Capital:          $218,450.00
Total Return:            +118.45%
CAGR:                    +16.89%
Sharpe Ratio:            1.72
Sortino Ratio:           2.24
Max Drawdown:            -14.30%
Closed Trades:           246
Win Rate:                56.10%
Profit Factor:           1.88
=================================================================

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 architecture and the minimal input contract

QuantStats is architected into three distinct layers:

  1. 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).
  2. 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).
  3. 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).

The minimal input contract

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 ParameterValid QuantStats SpecificationCommon Implementation ErrorAnalytical Consequence
Data Typepandas.Seriespandas.DataFrame (multi-column)Method errors or corrupted multi-asset aggregation
IndexSorted pd.DatetimeIndexInteger range or unordered datesCAGR, drawdown duration, and monthly heatmaps fail
Value ScaleFractional decimals (0.012 for +1.2%)Percentage integers (1.2) or raw equityVolatility and Sharpe ratio inflated by orders of magnitude
Inactive DaysExplicit 0.0 returnsnp.nan or omitted calendar rowsCalendar compression; volatility and Sharpe severely distorted
Benchmark IndexIdentical date bounds and frequencyMismatched trading holidays or unaligned hoursSilent inner-join truncations and distorted Alpha/Beta

Step-by-step: Generating a QuantStats backtest report in Python

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.

Environment setup

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.

Complete Python implementation

The following script simulates the conversion of an exported backtest equity curve into an audited QuantStats tear sheet:

Code
import numpy as np
import pandas as pd
import quantstats as qs

# Configure QuantStats rendering settings
qs.extend_pandas()

def generate_audited_tear_sheet() -> None:
    # 1. Simulate an exported daily backtest equity ledger (2022 to 2025)
    date_range = pd.date_range(start="2022-01-03", end="2025-12-31", freq="B")
    np.random.seed(42)
    
    # Synthesize realistic daily log returns with drift and volatility clustering
    daily_shocks = np.random.normal(loc=0.00045, scale=0.011, size=len(date_range))
    equity_curve = 100000.0 * np.exp(np.cumsum(daily_shocks))
    
    # Construct raw portfolio equity series
    equity_series = pd.Series(equity_curve, index=date_range, name="Strategy_Equity")
    
    # 2. Transform equity series into fractional percentage returns
    strategy_returns = equity_series.pct_change().dropna()
    strategy_returns.name = "Trend_Strategy"
    
    # Ensure all cash/flat periods are explicit zeros, not NaNs
    strategy_returns = strategy_returns.fillna(0.0)
    
    # 3. Construct an aligned benchmark return series (e.g., S&P 500 proxy)
    # Using local generation to guarantee deterministic offline execution
    bm_shocks = np.random.normal(loc=0.00032, scale=0.010, size=len(date_range))
    bm_equity = 100000.0 * np.exp(np.cumsum(bm_shocks))
    benchmark_returns = pd.Series(bm_equity, index=date_range, name="SPY").pct_change().dropna()
    benchmark_returns = benchmark_returns.fillna(0.0)

    # 4. Verify input contract preconditions
    assert isinstance(strategy_returns, pd.Series), "Returns must be a pandas Series"
    assert isinstance(strategy_returns.index, pd.DatetimeIndex), "Index must be DatetimeIndex"
    assert not strategy_returns.isna().any(), "Returns series must not contain NaNs"
    assert strategy_returns.index.is_monotonic_increasing, "Index must be sorted chronologically"

    print("--- COMPUTING QUANTSTATS SUMMARY METRICS ---")
    # Display high-density metrics directly to terminal
    qs.reports.metrics(
        returns=strategy_returns,
        benchmark=benchmark_returns,
        mode="basic",
        rf=0.0,
        periods_per_year=252
    )

    # 5. Export comprehensive interactive HTML tear sheet
    report_output_path = "quantstats_strategy_report.html"
    qs.reports.html(
        returns=strategy_returns,
        benchmark=benchmark_returns,
        output=report_output_path,
        title="Momentum Trend Strategy - Performance Audit",
        rf=0.0,
        periods_per_year=252
    )
    print(f"\n[SUCCESS] Standalone HTML tear sheet exported to: {report_output_path}")

if __name__ == "__main__":
    generate_audited_tear_sheet()

Execution output and terminal summary

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-04
End Period          2025-12-31         2025-12-31
Risk-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.76
Prob. Sharpe Ratio  85.22%             93.18%
Sortino             0.77               1.08
Sortino/√2          0.54               0.76
Omega               1.09               1.14

Max Drawdown        -18.42%            -15.11%
Longest DD Days     342                284

Gain/Loss Ratio     1.12               1.18
Profit Factor       1.09               1.14

Daily Value-at-Risk -1.61%             -1.78%
Expected Shortfall  -2.02%             -2.24%

Calmar              0.46               0.81
Skew                -0.08              0.02
Kurtosis            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.


The four critical reconciliation audits: Raw ledger vs. QuantStats report

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.

Code
flowchart LR
    subgraph Audit1 ["Audit 1: Annualization"]
        P1["periods_per_year = 252 (Equities)"]
        P2["periods_per_year = 365 (Crypto/Continuous)"]
        A1["Scale Factor: sqrt(P)"]
    end

    subgraph Audit2 ["Audit 2: Compounding"]
        C1["True NAV: prod(1 + r_t) - 1"]
        C2["Arithmetic Sum: sum(r_t) (DRIFT ERROR)"]
    end

    subgraph Audit3 ["Audit 3: Calendar Alignment"]
        D1["Explicit 0.0 for cash days"]
        D2["Benchmark Join: prevent silent row loss"]
    end

    subgraph Audit4 ["Audit 4: Risk-Free Rate"]
        R1["Default rf = 0.0%"]
        R2["Prevailing Cash Yield (e.g. 4.5%)"]
    end

1. Annualization conventions and trading day calibration

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:

Code
Annualized Volatility = Daily Volatility * sqrt(periods_per_year)

Annualized Sharpe = ((Mean Daily Return - Daily Rf) / Daily Volatility) * sqrt(periods_per_year)

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:

Code
Underestimation Ratio = sqrt(252) / sqrt(365) = 15.8745 / 19.1049 ≈ 0.8309

An uncalibrated 252-day assumption understates continuous market volatility by ~16.9%, which artificially distorts the Sharpe and Sortino ratios.

Code
# Equity strategies (US Exchanges):
qs.reports.metrics(strategy_returns, periods_per_year=252)

# Continuous 24/7 strategies (Crypto / FX):
qs.reports.metrics(strategy_returns, periods_per_year=365)

# Weekly frequency return series:
qs.reports.metrics(strategy_returns, periods_per_year=52)

2. Compounding conventions vs. arithmetic return summation

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.

3. Missing dates, weekends, and benchmark alignment

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:

  1. Volatility distortion: Standard deviation is calculated across high-action trading days only, ignoring the stabilizing zero-volatility cash periods.
  2. CAGR distortion: QuantStats calculates Compound Annual Growth Rate based on the calendar duration between the first and last timestamps in the series:
    Code
    CAGR = (1 + R_cumulative)^(periods_per_year / total_periods) - 1
    If dates are missing, the ratio between total periods and calendar duration breaks down.

Furthermore, when passing a benchmark (such as SPY), QuantStats performs an internal alignment:

Code
# QuantStats internal alignment logic
returns, benchmark = returns.align(benchmark, join='inner')

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.

4. Risk-free rate assumptions and excess return

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.


Troubleshooting real-world QuantStats errors

Deploying QuantStats within modern Python data pipelines frequently encounters legacy edge cases. Below are practical solutions to the four most common failure modes.

1. yfinance MultiIndex headers and download timeouts

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'
# or
TypeError: '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 Series
benchmark_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"
)

2. Pandas 2.0+ deprecations (fillna, iteritems, append)

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'
# or
FutureWarning: '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 quantstats
import pandas as pd
if not hasattr(pd.Series, "iteritems"):
    pd.Series.iteritems = pd.Series.items

3. Matplotlib GUI crashes on headless servers

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 matplotlib
matplotlib.use("Agg")  # Force headless rendering
import quantstats as qs

4. Timezone mismatch during benchmark joins

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 dates
strategy_returns.index = strategy_returns.index.tz_localize(None).normalize()
benchmark_returns.index = benchmark_returns.index.tz_localize(None).normalize()

Toolchain boundaries: OpenBacktesting Studio vs. QuantStats

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

Specialized roles in the quantitative workflow

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


Institutional backtest report audit checklist

Before presenting a QuantStats tear sheet to investment committees, allocators, or peer researchers, audit the report against this five-point technical verification checklist:

Verification CategoryAudit ProcedureWarning Signs & Failure SignalsRemediation Action
1. Input SanitizationVerify series is a 1D pd.Series with monotonic pd.DatetimeIndexInteger index; multi-column DataFrame; unsorted datesReindex with pd.to_datetime() and sort ascending with sort_index()
2. Return ScalingInspect return series magnitude: abs(returns.mean()) should be &lt; 0.05Values like 1.5 or 150.0 representing 1.5% returnsDivide raw percentage integers by 100 to yield fractional decimals
3. Calendar CalibrationVerify periods_per_year matches underlying asset trading rulesCrypto strategy evaluated with periods_per_year=252Set periods_per_year=365 for continuous 24/7 markets; 252 for equity exchanges
4. Inactive Period HandlingVerify non-trading cash days are explicit 0.0 valuesMissing dates on days when portfolio held 100% cashReindex series against complete market trading calendar; fillna(0.0)
5. Ledger ReconciliationCross-check QuantStats Cumulative Return with raw ledger balanceEnding cash + position in SQLite DB differs from prod(1 + r) - 1Investigate unmodeled cash flows, margin interest, or dividend accounting gaps
6. Benchmark SynchronizationConfirm identical timezone localization across strategy and benchmarkTypeError: Cannot join tz-naive with tz-awareStrip timezones using .tz_localize(None).normalize() on both inputs
7. Risk-Free Rate AlignmentCalibrate rf against prevailing short-term government yieldsSharpe ratio calculated with rf=0.0 in high interest-rate regimesSpecify annualized cash hurdle rate (e.g., rf=0.045) in qs.reports

Conclusion

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.