Insights
How to Plot an Equity Curve in Python
Alphanume Team · June 3, 2026
From returns to a clean cumulative chart.
An equity curve python traders reach for first — a single line that converts a sequence of daily returns into a picture of how wealth compounds. It is also one of the easiest charts to get subtly wrong: wrong starting point, missing benchmark, drawdown buried in a footnote. This tutorial builds the curve correctly from a returns Series, adds a benchmark overlay and a drawdown subplot, annotates key statistics, and wraps everything in a reusable function. Along the way, we cover a few presentation choices that can make a good strategy look mediocre and a mediocre one look good.
Why (1+r).cumprod() beats cumsum
The textbook shortcut for an equity curve is returns.cumsum(), which adds returns together. That is arithmetic accumulation — it ignores the compounding effect of reinvesting gains. A +50 % gain followed by a −50 % loss nets to zero under arithmetic math but produces a terminal wealth of 0.75 under geometric math, which is what actually happens when you hold a position.
The correct transform is geometric: convert each return to a growth factor, chain-multiply them, and you get the compounded wealth index.
import pandas as pd
import numpy as np
# `returns` is a pd.Series of daily returns, e.g. 0.012, -0.005, ...
# Align to a DatetimeIndex and drop NaN before passing in.
def wealth_index(returns: pd.Series, start: float = 1.0) -> pd.Series:
"""Compound a returns series into a wealth index starting at `start`."""
if returns.isna().any():
returns = returns.dropna()
return start * (1 + returns).cumprod()
Start at 1.0 to read the terminal value as a multiple of initial capital (2.4x means you tripled). Use 100.0 if you prefer a percentage-of-NAV axis — either is fine as long as the axis label is clear. What matters is that both the strategy and any benchmark share the same starting value and the same starting date, otherwise the comparison is meaningless.
Handling missing data and date alignment
A returns series almost always has gaps: weekends, holidays, early history missing for a new ticker. The safest rule is to drop leading NaN rows before compounding and, when you have two series to compare, align them to their shared date range before either curve is computed. Pandas DataFrame.dropna(how="any") applied to the combined frame does both in one step.
def align_series(
strategy: pd.Series,
benchmark: pd.Series
) -> tuple[pd.Series, pd.Series]:
"""
Align two return series to their shared non-null date range.
Both series must carry a DatetimeIndex.
"""
combined = pd.DataFrame(
{"strategy": strategy, "benchmark": benchmark}
).dropna(how="any")
return combined["strategy"], combined["benchmark"]
This is especially important if you are calculating rolling returns in pandas for a momentum signal — a shifted index will silently misalign your equity curve relative to the benchmark if you skip the alignment step.
Building the plot — log scale and why it matters
On a linear y-axis, a doubling from 1 to 2 looks identical in height to a doubling from 50 to 100, even though both represent the same percentage gain. Over long horizons, the early years are visually crushed and the recent blow-off dominates. A log-scale y-axis fixes this: equal vertical distances represent equal percentage moves, so the chart is perceptually fair.
Use ax.set_yscale("log") and format the ticks as multiples rather than raw log values. For short backtests (under three years) a linear scale is fine; the distortion only compounds over time, literally.
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
def plot_equity_curve(
returns: pd.Series,
benchmark: pd.Series | None = None,
log_scale: bool = True,
start: float = 1.0,
title: str = "Equity Curve",
) -> plt.Figure:
"""
Plot a compounded equity curve with an optional benchmark overlay
and a drawdown subplot.
Parameters
----------
returns : pd.Series of daily simple returns with DatetimeIndex
benchmark : optional pd.Series of daily simple returns
log_scale : use log y-axis on the equity panel (default True)
start : starting wealth level (1.0 or 100.0)
title : figure title
"""
# --- align and build wealth indexes ---------------------------------
if benchmark is not None:
returns, benchmark = align_series(returns, benchmark)
strat_wi = wealth_index(returns, start=start)
# --- drawdown series ------------------------------------------------
running_max = strat_wi.cummax()
drawdown = strat_wi / running_max - 1.0
# --- layout ---------------------------------------------------------
fig, (ax_eq, ax_dd) = plt.subplots(
2, 1,
figsize=(11, 7),
sharex=True,
gridspec_kw={"height_ratios": [3, 1]},
)
fig.suptitle(title, fontsize=13, fontweight="bold")
# equity panel
ax_eq.plot(strat_wi.index, strat_wi.values,
linewidth=1.6, label="Strategy", color="#1a6faf")
if benchmark is not None:
bm_wi = wealth_index(benchmark, start=start)
ax_eq.plot(bm_wi.index, bm_wi.values,
linewidth=1.2, linestyle="--",
label="Benchmark", color="#888888")
if log_scale:
ax_eq.set_yscale("log")
ax_eq.yaxis.set_major_formatter(
mticker.FuncFormatter(lambda y, _: f"{y:.1f}x")
)
ax_eq.set_ylabel("Wealth (x initial)" if start == 1.0 else "NAV")
ax_eq.legend(frameon=False)
ax_eq.grid(axis="y", linestyle=":", linewidth=0.5, alpha=0.7)
# drawdown panel
ax_dd.fill_between(drawdown.index, drawdown.values,
0, color="#c0392b", alpha=0.5)
ax_dd.set_ylabel("Drawdown")
ax_dd.yaxis.set_major_formatter(
mticker.PercentFormatter(xmax=1.0, decimals=0)
)
ax_dd.grid(axis="y", linestyle=":", linewidth=0.5, alpha=0.7)
# --- annotations ----------------------------------------------------
cagr = strat_wi.iloc[-1] ** (252 / len(returns)) - 1
max_dd = drawdown.min()
ann_vol = returns.std() * np.sqrt(252)
sharpe = (returns.mean() * 252) / ann_vol if ann_vol > 0 else np.nan
stats_text = (
f"CAGR: {cagr:.1%} | "
f"Vol: {ann_vol:.1%} | "
f"Sharpe: {sharpe:.2f} | "
f"Max DD: {max_dd:.1%}"
)
fig.text(
0.5, 0.01, stats_text,
ha="center", fontsize=9, color="#444444"
)
plt.tight_layout(rect=[0, 0.04, 1, 1])
return fig
The drawdown subplot
The drawdown at any point in time is how far the portfolio sits below its previous peak — formally, wealth / running_max - 1. It is always zero or negative. Plotting it as a filled area beneath the equity curve creates an immediately readable picture: wide, deep red patches are prolonged losing periods; the deepest trough is the maximum drawdown you would have lived through as an investor.
Keep the drawdown panel short relative to the equity panel — a 3:1 height ratio works well — and share the x-axis so zooming either panel zooms both. Do not plot it separately; the temporal relationship between the equity curve and its underwater periods is most of the information.
If you want to go further and decompose risk numerically, the next step is computing Sharpe and Sortino ratios in Python, which converts the same returns series into per-unit-of-risk metrics that complement the visual.
Annotating key statistics
The four numbers worth annotating on any equity curve are CAGR, annualised volatility, Sharpe ratio, and maximum drawdown. They appear at the bottom of the figure in the function above. A few implementation notes:
- CAGR exponent.
terminal_wealth ** (252 / n_days) - 1assumes roughly 252 trading days per year. If your series has calendar gaps, count actual trading days, not calendar days. - Volatility scaling. Daily standard deviation times
sqrt(252)is correct for independent returns. It slightly overstates vol if returns are autocorrelated, which matters for mean-reversion strategies. - Sharpe. The formula above uses a zero risk-free rate for simplicity. Subtract the daily risk-free rate from each return before computing mean and std if you want the full ratio.
A note on what the curve is not
A smooth upward-sloping equity curve is compelling, but it is not validation. In-sample overfitting produces beautiful curves. Transaction costs, slippage, and capacity effects that are absent from a backtest can erase the edge entirely. Survivorship bias in the universe selection can manufacture returns that never existed. For a full discussion of the data inputs that feed into a clean backtest, the API documentation covers the point-in-time datasets that remove look-ahead before the first bar is touched. Build the curve honestly — start date, benchmark, costs included — and treat it as one diagnostic among several, not a conclusion.