Skip to content

Plots

Figure builders

fundcloud.plots.cumulative

cumulative(
    returns: Series | DataFrame,
    *,
    benchmark: Series | None = None,
    title: str = "Cumulative return (%)",
    theme: str | None = None,
    annotations: bool = False,
) -> go.Figure

Cumulative-return curve (0% at inception, percent-formatted y-axis).

returns may be a :class:pandas.Series or a :class:pandas.DataFrame (one line per column). benchmark is drawn as a dashed reference. Each series is dropna()'d before compounding so staggered inception dates produce continuous lines from their own first observation rather than broken/dashed segments.

Source code in python/fundcloud/plots/plotly.py
def cumulative(
    returns: pd.Series | pd.DataFrame,
    *,
    benchmark: pd.Series | None = None,
    title: str = "Cumulative return (%)",
    theme: str | None = None,
    annotations: bool = False,
) -> go.Figure:
    """Cumulative-return curve (0% at inception, percent-formatted y-axis).

    ``returns`` may be a :class:`pandas.Series` or a :class:`pandas.DataFrame`
    (one line per column). ``benchmark`` is drawn as a dashed reference. Each
    series is ``dropna()``'d before compounding so staggered inception dates
    produce continuous lines from their own first observation rather than
    broken/dashed segments.
    """
    fig = go.Figure()
    series_list = to_series_list(returns)
    for name, series in series_list:
        clean = series.dropna()
        cum = (1.0 + clean).cumprod() - 1.0
        fig.add_trace(
            go.Scatter(
                x=cum.index,
                y=cum.values,
                name=name,
                mode="lines",
                line={"width": 2},
                connectgaps=True,
            )
        )
    if benchmark is not None:
        bench_clean = benchmark.dropna()
        bench_cum = (1.0 + bench_clean).cumprod() - 1.0
        fig.add_trace(
            go.Scatter(
                x=bench_cum.index,
                y=bench_cum.values,
                name=str(benchmark.name) if benchmark.name is not None else "benchmark",
                line={"color": "#888", "width": 1.5, "dash": "dash"},
                connectgaps=True,
            )
        )
    if annotations:
        _stats_pill(fig, _ann.cumulative_pill(series_list, benchmark=benchmark))
    return _style(fig, title=title, theme=theme, y_tick_format=".0%")

fundcloud.plots.drawdown

drawdown(
    returns: Series | DataFrame,
    *,
    title: str = "Drawdown (%)",
    theme: str | None = None,
    annotations: bool = False,
) -> go.Figure

Underwater (drawdown) chart.

Accepts a :class:pandas.Series or a multi-column :class:pandas.DataFrame; each column is rendered as its own filled area with reduced opacity so overlaps stay legible.

Source code in python/fundcloud/plots/plotly.py
def drawdown(
    returns: pd.Series | pd.DataFrame,
    *,
    title: str = "Drawdown (%)",
    theme: str | None = None,
    annotations: bool = False,
) -> go.Figure:
    """Underwater (drawdown) chart.

    Accepts a :class:`pandas.Series` or a multi-column
    :class:`pandas.DataFrame`; each column is rendered as its own filled
    area with reduced opacity so overlaps stay legible.
    """
    fig = go.Figure()
    series_list = to_series_list(returns)
    single = len(series_list) == 1
    for name, series in series_list:
        clean = series.dropna()
        dd = _metrics.drawdown_series(clean) * 100.0
        fig.add_trace(
            go.Scatter(
                x=dd.index,
                y=dd.values,
                name=name,
                mode="lines",
                fill="tozeroy" if single else None,
                line={"width": 1.2} if single else {"width": 1.5},
                opacity=1.0 if single else 0.9,
                connectgaps=True,
            )
        )
    if annotations:
        _stats_pill(fig, _ann.drawdown_pill(series_list))
    return _style(fig, title=title, theme=theme, y_tick_format=".1f")

fundcloud.plots.rolling_sharpe

rolling_sharpe(
    returns: Series | DataFrame,
    *,
    window: int = 63,
    periods_per_year: int = 252,
    title: str | None = None,
    theme: str | None = None,
    annotations: bool = False,
) -> go.Figure

Rolling annualised Sharpe (window-period window).

Multi-column input overlays one line per column.

Source code in python/fundcloud/plots/plotly.py
def rolling_sharpe(
    returns: pd.Series | pd.DataFrame,
    *,
    window: int = 63,
    periods_per_year: int = 252,
    title: str | None = None,
    theme: str | None = None,
    annotations: bool = False,
) -> go.Figure:
    """Rolling annualised Sharpe (``window``-period window).

    Multi-column input overlays one line per column.
    """
    fig = go.Figure()
    series_list = to_series_list(returns)
    for name, series in series_list:
        clean = series.dropna()
        mu = clean.rolling(window).mean()
        sigma = clean.rolling(window).std(ddof=1)
        rs = (mu / sigma) * np.sqrt(periods_per_year)
        fig.add_trace(
            go.Scatter(
                x=rs.index,
                y=rs.values,
                name=f"{name} ({window}-bar)",
                mode="lines",
                line={"width": 1.5},
                connectgaps=True,
            )
        )
    fig.add_hline(y=0, line={"color": "rgba(0,0,0,0.4)", "width": 1, "dash": "dot"})
    if annotations:
        _ann.annotate_full_period_sharpe(
            fig, _metrics.sharpe(returns, periods_per_year=periods_per_year)
        )
    return _style(
        fig,
        title=title or f"Rolling Sharpe ({window} bars)",
        theme=theme,
        y_tick_format=".2f",
    )

fundcloud.plots.return_distribution

return_distribution(
    returns: Series | DataFrame,
    *,
    bins: int = 60,
    title: str = "Return distribution (%)",
    theme: str | None = None,
    annotations: bool = False,
) -> go.Figure

Histogram of per-period returns (in %).

Multi-column input overlays translucent histograms.

Source code in python/fundcloud/plots/plotly.py
def return_distribution(
    returns: pd.Series | pd.DataFrame,
    *,
    bins: int = 60,
    title: str = "Return distribution (%)",
    theme: str | None = None,
    annotations: bool = False,
) -> go.Figure:
    """Histogram of per-period returns (in %).

    Multi-column input overlays translucent histograms.
    """
    fig = go.Figure()
    series_list = to_series_list(returns)
    for name, series in series_list:
        fig.add_trace(
            go.Histogram(
                x=series.values * 100.0,
                nbinsx=bins,
                name=name,
                opacity=0.85 if len(series_list) == 1 else 0.55,
            )
        )
    if len(series_list) > 1:
        fig.update_layout(barmode="overlay")
    if annotations:
        _ann.annotate_var_cvar(fig, series_list)
        _stats_pill(fig, _ann.distribution_pill(series_list))
    return _style(fig, title=title, theme=theme, y_tick_format="d")

fundcloud.plots.monthly_heatmap

monthly_heatmap(
    returns: Series | DataFrame,
    *,
    title: str = "Monthly returns (%)",
    theme: str | None = None,
    annotations: bool = False,
    colorbar: dict[str, object] | None = None,
    text_values: bool = True,
) -> go.Figure

Year × month heatmap of aggregated returns.

Requires a :class:pandas.Series or a single-column :class:pandas.DataFrame — overlaying heatmaps is not meaningful. Pass one column for multi-asset frames, or call :func:fundcloud.plots.summary.

text_values=True (default) renders each cell's percent-return directly on the tile so readers don't need to colour-match against the scale bar. Pass False on very dense panels (e.g. > 20 years) if the numbers crowd out.

Source code in python/fundcloud/plots/plotly.py
def monthly_heatmap(
    returns: pd.Series | pd.DataFrame,
    *,
    title: str = "Monthly returns (%)",
    theme: str | None = None,
    annotations: bool = False,
    colorbar: dict[str, object] | None = None,
    text_values: bool = True,
) -> go.Figure:
    """Year × month heatmap of aggregated returns.

    Requires a :class:`pandas.Series` or a single-column
    :class:`pandas.DataFrame` — overlaying heatmaps is not meaningful. Pass
    one column for multi-asset frames, or call :func:`fundcloud.plots.summary`.

    ``text_values=True`` (default) renders each cell's percent-return
    directly on the tile so readers don't need to colour-match against the
    scale bar. Pass ``False`` on very dense panels (e.g. > 20 years) if
    the numbers crowd out.
    """
    series = to_single_series(returns, caller="monthly_heatmap")
    # dropna first so pre-inception periods (e.g. BTC-USD prior to 2014 when
    # combined with an asset that started earlier) don't pad the pivot with
    # bogus zero-return months that squash the colour scale and compress
    # row heights.
    series = series.dropna().copy()
    if series.empty:
        fig = go.Figure()
        return _style(fig, title=title, theme=theme)
    series.index = pd.DatetimeIndex(series.index)
    monthly = ((1.0 + series).resample("ME").prod() - 1.0) * 100.0
    table = pd.pivot_table(
        monthly.to_frame("ret"),
        index=monthly.index.year,
        columns=monthly.index.month,
        values="ret",
    )
    months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
    cols = [months[m - 1] for m in table.columns]

    cb = {"title": "%", "thickness": 10}
    if colorbar is not None:
        cb = {**cb, **colorbar}

    heatmap_kwargs: dict[str, object] = {
        "z": table.values,
        "x": cols,
        "y": table.index.astype(str),
        "colorscale": _DIVERGING_SCALE,
        "zmid": 0,
        "colorbar": cb,
        "hovertemplate": "%{y} %{x}: %{z:.2f}%<extra></extra>",
    }
    if text_values:
        heatmap_kwargs["texttemplate"] = "%{z:.1f}"
        heatmap_kwargs["textfont"] = {"size": 10, "color": "#1c1c1c"}

    fig = go.Figure(data=go.Heatmap(**heatmap_kwargs))
    # type='category' + autorange='reversed' gives newest-at-top while
    # letting plotly auto-scale all 13+ years into the available vertical
    # space. Forcing tickmode='array' + explicit tickvals (as we tried
    # earlier) interacts badly with plotly's row_heights allocation in
    # the composite summary and collapses cells to near-zero height.
    fig.update_yaxes(autorange="reversed", type="category")
    if annotations:
        _ann.annotate_heatmap_margins(fig, table)
    return _style(fig, title=title, theme=theme)

fundcloud.plots.composition

composition(
    weights: DataFrame,
    *,
    title: str = "Portfolio composition",
    theme: str | None = None,
    annotations: bool = False,
) -> go.Figure

Stacked-area chart of per-asset weight over time.

Source code in python/fundcloud/plots/plotly.py
def composition(
    weights: pd.DataFrame,
    *,
    title: str = "Portfolio composition",
    theme: str | None = None,
    annotations: bool = False,
) -> go.Figure:
    """Stacked-area chart of per-asset weight over time."""
    if weights.empty:
        return _style(go.Figure(), title=title, theme=theme, y_tick_format=".0%")
    fig = go.Figure()
    for asset in weights.columns:
        fig.add_trace(
            go.Scatter(
                x=weights.index,
                y=weights[asset],
                mode="lines",
                name=str(asset),
                stackgroup="one",
                line={"width": 0.5},
            )
        )
    resolved_title = title
    if annotations:
        turnover = _ann.turnover(weights)
        resolved_title = f"{title} — avg turnover {turnover:.2%}/period"
    return _style(fig, title=resolved_title, theme=theme, y_tick_format=".0%")

Aggregator

fundcloud.plots.summary

summary(
    returns: Series | DataFrame,
    *,
    benchmark: Series | str | None = None,
    weights: DataFrame | None = None,
    theme: str | None = None,
    title: str | None = None,
    heatmap_asset: str | None = None,
) -> go.Figure

Return a composite figure with the canonical tearsheet panels.

Each section uses a full-width row — easier to read than a tiled grid:

== ============================================================= row content == ============================================================= 1 Cumulative returns 2 Drawdown (%) 3 Rolling Sharpe 4 Return distribution (%) 5 Monthly returns heatmap (first series) 6 Portfolio composition (only when weights is supplied) == =============================================================

Annotations (annotations=True) are applied per panel — stats pills, VaR/CVaR reference lines, full-period Sharpe reference, heatmap cell values. A single color per asset is maintained across rows, and each asset contributes one legend entry rather than one per panel.

Source code in python/fundcloud/plots/aggregated.py
def summary(
    returns: pd.Series | pd.DataFrame,
    *,
    benchmark: pd.Series | str | None = None,
    weights: pd.DataFrame | None = None,
    theme: str | None = None,
    title: str | None = None,
    heatmap_asset: str | None = None,
) -> go.Figure:
    """Return a composite figure with the canonical tearsheet panels.

    Each section uses a full-width row — easier to read than a tiled grid:

    ==  =============================================================
    row content
    ==  =============================================================
    1   Cumulative returns
    2   Drawdown (%)
    3   Rolling Sharpe
    4   Return distribution (%)
    5   Monthly returns heatmap (first series)
    6   Portfolio composition (only when ``weights`` is supplied)
    ==  =============================================================

    Annotations (``annotations=True``) are applied per panel — stats pills,
    VaR/CVaR reference lines, full-period Sharpe reference, heatmap cell
    values. A single color per asset is maintained across rows, and each
    asset contributes one legend entry rather than one per panel.
    """
    # Resolve string benchmark against a DataFrame's column first, so the
    # single-column heatmap asset also sees the string if it matches.
    benchmark = _resolve_benchmark(returns, benchmark)

    include_composition = weights is not None and not weights.empty
    include_benchmark = benchmark is not None
    series_list = to_series_list(returns)
    series_by_name = dict(series_list)

    # Monthly heatmap behaviour: one row per asset when the input is
    # multi-asset, a single row when it's a single series. ``heatmap_asset=``
    # narrows that to one named column.
    if heatmap_asset is not None:
        if heatmap_asset not in series_by_name:
            known = [name for name, _ in series_list]
            msg = f"heatmap_asset={heatmap_asset!r} not in returns columns {known}"
            raise ValueError(msg)
        heatmap_assets = [heatmap_asset]
    else:
        heatmap_assets = [name for name, _ in series_list]

    # Build row layout top-to-bottom. Each layout entry is (kind, asset|None):
    # only ``monthly_heatmap`` rows carry an asset name — other panels already
    # show every asset together.
    layout: list[tuple[str, str | None]] = [
        ("cumulative", None),
        ("drawdown", None),
        ("rolling_sharpe", None),
    ]
    if include_benchmark:
        layout.append(("rolling_alpha", None))
        layout.append(("rolling_beta", None))
    layout.append(("return_distribution", None))
    for hm_name in heatmap_assets:
        layout.append(("monthly_heatmap", hm_name))
    if include_composition:
        layout.append(("composition", None))

    title_base = {
        "cumulative": "Cumulative returns",
        "drawdown": "Drawdown (%)",
        "rolling_sharpe": "Rolling Sharpe",
        "rolling_alpha": "Rolling alpha (annualised)",
        "rolling_beta": "Rolling beta",
        "return_distribution": "Return distribution (%)",
        "composition": "Portfolio composition",
    }

    def _panel_title(kind: str, asset: str | None) -> str:
        if kind == "monthly_heatmap":
            if len(series_list) > 1 or heatmap_asset is not None:
                return f"Monthly returns (%) — {asset}"
            return "Monthly returns (%)"
        return title_base[kind]

    subplot_titles = [_panel_title(k, a) for k, a in layout]
    rows = len(layout)
    # Row index (1-based) for each heatmap, needed so each colorbar can be
    # anchored to its own panel.
    heatmap_rows = [i for i, (k, _) in enumerate(layout, start=1) if k == "monthly_heatmap"]

    # Every row is a single full-width cell — no colspan gymnastics.
    specs: list[list[dict[str, Any]]] = [[{}] for _ in range(rows)]

    # Give each monthly heatmap ~1.6x the baseline row so 13+ years by 12
    # months with in-cell text don't squish into a single visible strip.
    row_weights = [1.6 if k == "monthly_heatmap" else 1.0 for k, _ in layout]
    total = sum(row_weights)
    row_heights = [w / total for w in row_weights]

    fig = make_subplots(
        rows=rows,
        cols=1,
        specs=specs,
        subplot_titles=subplot_titles,
        vertical_spacing=0.055,
        row_heights=row_heights,
    )

    # Build each sub-figure with annotations=True so stats pills land on
    # the composite. Heatmap colorbars are anchored to their rows explicitly.
    cum = _plt.cumulative(returns, benchmark=benchmark, annotations=True)
    dd = _plt.drawdown(returns, annotations=True)
    rs = _plt.rolling_sharpe(returns, annotations=True)
    dist = _plt.return_distribution(returns, annotations=True)

    # annotations=False is intentional: the heatmap's in-cell texttemplate
    # already shows each month's return. Adding the annotate_heatmap_margins
    # annotations (annual totals + monthly averages) causes plotly to extend
    # the category axis range inside the composite subplot, collapsing cells
    # to near-zero height. Keep every composite heatmap clean; the margins
    # annotations are available when the heatmap is rendered standalone.
    heatmaps: dict[str, go.Figure] = {
        name: _plt.monthly_heatmap(
            series_by_name[name].rename(name),
            annotations=False,
            colorbar=_heatmap_colorbar_from_weights(row_heights, heatmap_row=hm_row),
        )
        for name, hm_row in zip(heatmap_assets, heatmap_rows, strict=True)
    }

    sub_regular: dict[str, go.Figure] = {
        "cumulative": cum,
        "drawdown": dd,
        "rolling_sharpe": rs,
        "return_distribution": dist,
    }
    if include_benchmark:
        sub_regular["rolling_alpha"] = _rolling_alpha_figure(returns, benchmark)
        sub_regular["rolling_beta"] = _rolling_beta_figure(returns, benchmark)
    if include_composition:
        sub_regular["composition"] = _plt.composition(weights, annotations=True)

    for i, (kind, asset_name) in enumerate(layout, start=1):
        sub = heatmaps[asset_name] if kind == "monthly_heatmap" else sub_regular[kind]
        _place(fig, sub, row=i)

    # Consistent per-asset color + legend dedup across panels.
    _unify_asset_colors_and_legend(fig)

    # Global chrome. Figure height follows the row weights so the heatmap
    # gets a proportional share of vertical space (not a tiny 1/N slot).
    template = _resolve_template(theme)
    fig.update_layout(
        height=int(280 * total) + 120,
        title={"text": title or "Strategy summary", "x": 0.01},
        showlegend=True,
        legend={
            "orientation": "h",
            "yanchor": "bottom",
            "y": 1.02,
            "xanchor": "right",
            "x": 1.0,
        },
        margin={"l": 60, "r": 40, "t": 90, "b": 60},
    )
    if template is None:
        fig.update_layout(
            plot_bgcolor="white",
            paper_bgcolor="white",
            font={"family": "Inter, system-ui, sans-serif", "size": 12},
        )
        fig.update_xaxes(gridcolor="rgba(0,0,0,0.08)", zerolinecolor="rgba(0,0,0,0.2)")
        fig.update_yaxes(gridcolor="rgba(0,0,0,0.08)", zerolinecolor="rgba(0,0,0,0.2)")
    else:
        fig.update_layout(template=template)
    return fig

Theming

fundcloud.plots.set_theme

set_theme(name: str) -> None

Select the plotly template used by subsequent fundcloud figures.

Parameters:

Name Type Description Default
name str

A fundcloud alias ("default", "white", "dark", "ggplot2", "seaborn") or any name registered in plotly.io.templates.

required
Source code in python/fundcloud/plots/themes.py
def set_theme(name: str) -> None:
    """Select the plotly template used by subsequent fundcloud figures.

    Parameters
    ----------
    name
        A fundcloud alias (``"default"``, ``"white"``, ``"dark"``,
        ``"ggplot2"``, ``"seaborn"``) or any name registered in
        ``plotly.io.templates``.
    """
    global _active
    _validate(name)
    _active = name

fundcloud.plots.get_theme

get_theme() -> str

Return the currently active theme name.

Source code in python/fundcloud/plots/themes.py
def get_theme() -> str:
    """Return the currently active theme name."""
    return _active

Matplotlib mirrors

fundcloud.plots.mpl

Matplotlib-backed mirrors of the plotly builders.

Each figure-level public builder has a private _build_<name>(ax, ...) companion that draws into a supplied :class:~matplotlib.axes.Axes. This split lets :func:summary compose panels via :class:~matplotlib.gridspec.GridSpec without spinning up and harvesting independent :class:matplotlib.figure.Figure objects.

Theming is intentionally a plotly-only concern (see :mod:fundcloud.plots.themes). The matplotlib builders keep their static styling.

Requires fundcloud[viz] for matplotlib.

cumulative

cumulative(
    returns: Series | DataFrame,
    *,
    benchmark: Series | None = None,
    title: str = "Cumulative return (%)",
    annotations: bool = False,
) -> Any
Source code in python/fundcloud/plots/mpl.py
def cumulative(
    returns: pd.Series | pd.DataFrame,
    *,
    benchmark: pd.Series | None = None,
    title: str = "Cumulative return (%)",
    annotations: bool = False,
) -> Any:
    fig, ax = _make_fig(title)
    _build_cumulative(ax, returns, benchmark=benchmark, title=title, annotations=annotations)
    fig.tight_layout()
    return fig

drawdown

drawdown(
    returns: Series | DataFrame,
    *,
    title: str = "Drawdown (%)",
    annotations: bool = False,
) -> Any
Source code in python/fundcloud/plots/mpl.py
def drawdown(
    returns: pd.Series | pd.DataFrame,
    *,
    title: str = "Drawdown (%)",
    annotations: bool = False,
) -> Any:
    fig, ax = _make_fig(title)
    _build_drawdown(ax, returns, title=title, annotations=annotations)
    fig.tight_layout()
    return fig

rolling_sharpe

rolling_sharpe(
    returns: Series | DataFrame,
    *,
    window: int = 63,
    periods_per_year: int = 252,
    title: str | None = None,
    annotations: bool = False,
) -> Any
Source code in python/fundcloud/plots/mpl.py
def rolling_sharpe(
    returns: pd.Series | pd.DataFrame,
    *,
    window: int = 63,
    periods_per_year: int = 252,
    title: str | None = None,
    annotations: bool = False,
) -> Any:
    fig, ax = _make_fig(title or f"Rolling Sharpe ({window} bars)")
    _build_rolling_sharpe(
        ax,
        returns,
        window=window,
        periods_per_year=periods_per_year,
        title=title,
        annotations=annotations,
    )
    fig.tight_layout()
    return fig

return_distribution

return_distribution(
    returns: Series | DataFrame,
    *,
    bins: int = 60,
    title: str = "Return distribution (%)",
    annotations: bool = False,
) -> Any
Source code in python/fundcloud/plots/mpl.py
def return_distribution(
    returns: pd.Series | pd.DataFrame,
    *,
    bins: int = 60,
    title: str = "Return distribution (%)",
    annotations: bool = False,
) -> Any:
    fig, ax = _make_fig(title)
    _build_return_distribution(ax, returns, bins=bins, title=title, annotations=annotations)
    fig.tight_layout()
    return fig

monthly_heatmap

monthly_heatmap(
    returns: Series | DataFrame,
    *,
    title: str = "Monthly returns (%)",
    annotations: bool = False,
) -> Any
Source code in python/fundcloud/plots/mpl.py
def monthly_heatmap(
    returns: pd.Series | pd.DataFrame,
    *,
    title: str = "Monthly returns (%)",
    annotations: bool = False,
) -> Any:
    _, plt = _require_mpl()
    series = to_single_series(returns, caller="monthly_heatmap")
    span = pd.DatetimeIndex(series.index)
    years_approx = max(int((span.max() - span.min()).days / 365) + 1, 3)
    fig, ax = plt.subplots(figsize=(8, max(2.4, 0.45 * years_approx)), dpi=120)
    im = _build_monthly_heatmap(ax, series, title=title, annotations=annotations)
    fig.colorbar(im, ax=ax, shrink=0.6, label="%")
    fig.tight_layout()
    return fig

composition

composition(
    weights: DataFrame,
    *,
    title: str = "Portfolio composition",
    annotations: bool = False,
) -> Any
Source code in python/fundcloud/plots/mpl.py
def composition(
    weights: pd.DataFrame,
    *,
    title: str = "Portfolio composition",
    annotations: bool = False,
) -> Any:
    fig, ax = _make_fig(title)
    _build_composition(ax, weights, title=title, annotations=annotations)
    fig.tight_layout()
    return fig

summary

summary(
    returns: Series | DataFrame,
    *,
    benchmark: Series | None = None,
    weights: DataFrame | None = None,
    title: str | None = None,
) -> Any

Matplotlib counterpart of :func:fundcloud.plots.summary.

Returns a single :class:matplotlib.figure.Figure composing the same canonical panels via :class:~matplotlib.gridspec.GridSpec.

Source code in python/fundcloud/plots/mpl.py
def summary(
    returns: pd.Series | pd.DataFrame,
    *,
    benchmark: pd.Series | None = None,
    weights: pd.DataFrame | None = None,
    title: str | None = None,
) -> Any:
    """Matplotlib counterpart of :func:`fundcloud.plots.summary`.

    Returns a single :class:`matplotlib.figure.Figure` composing the same
    canonical panels via :class:`~matplotlib.gridspec.GridSpec`.
    """
    import matplotlib.gridspec as gridspec

    _, plt = _require_mpl()
    include_composition = weights is not None and not weights.empty
    rows = 4 if include_composition else 3
    height = 2.6 * rows + 0.8
    fig = plt.figure(figsize=(12, height), dpi=120, constrained_layout=True)
    gs = gridspec.GridSpec(rows, 2, figure=fig, hspace=0.55, wspace=0.22)

    ax_cum = fig.add_subplot(gs[0, :])
    _build_cumulative(ax_cum, returns, benchmark=benchmark, annotations=True)

    ax_dd = fig.add_subplot(gs[1, 0])
    _build_drawdown(ax_dd, returns, annotations=True)

    ax_rs = fig.add_subplot(gs[1, 1])
    _build_rolling_sharpe(ax_rs, returns, annotations=True)

    ax_dist = fig.add_subplot(gs[2, 0])
    _build_return_distribution(ax_dist, returns, annotations=True)

    ax_heat = fig.add_subplot(gs[2, 1])
    first_name, first_series = to_series_list(returns)[0]
    im = _build_monthly_heatmap(
        ax_heat,
        first_series.rename(first_name),
        annotations=True,
    )
    fig.colorbar(im, ax=ax_heat, shrink=0.7, label="%")

    if include_composition:
        ax_comp = fig.add_subplot(gs[3, :])
        _build_composition(ax_comp, weights, annotations=True)

    fig.suptitle(title or "Strategy summary", x=0.02, ha="left", fontsize=13, fontweight="600")
    return fig