Nautilus Visualization

The Nautilus [visualization] extras package is a Plotly-based interactive tearsheet system (NOT Bokeh) that renders self-contained HTML reports from backtest results. Install via uv pip install "nautilus_trader[visualization]" (depends on plotly>=6.3.1). The system has three parts: a chart registry with eight built-in charts (run_info, stats_table, equity, drawdown, monthly_returns, distribution, rolling_sharpe, yearly_returns, bars_with_fills), a theme system (four built-ins plus custom registration), and TearsheetConfig declarative composition. Output is a single HTML file per backtest, embeddable anywhere a browser opens. It is backtest-only - there is no live dashboard surface in this extras package. For Cortana MK3 this is one of three parallel visualization surfaces: (1) preserved MK2 operator dashboard repointed at Nautilus cache for live ops, (2) Nautilus tearsheets for backtest research artifacts, (3) ML Dashboard #81 for per-tenant ML transparency. The three are complementary, not competing.

This page specializes Nautilus Concepts at the visualization layer, runs parallel to Nautilus Portfolio and Nautilus Reports (which are the programmatic analysis surfaces - this page is the rendered surface that consumes their output), and depends on Nautilus Cache only indirectly (the dashboard repoint reads cache, not tearsheets).

Why this page exists

The MK3 roadmap commits to preserving the existing MK2 operator dashboard (HTML/CSS/JS) by repointing its data layer at the Nautilus cache. Separately, it commits to using nautilus_trader[visualization] for backtest tearsheets. Separately again, it preserves ML Dashboard #81 as a per-tenant ML transparency view. Three surfaces. This page documents the middle one - what the extras package actually provides - so the question “do we still need to hand-build the equity-curve / return-distribution panels for ML Dashboard #81?” has a sourced answer.

Core claim

“NautilusTrader provides interactive HTML tearsheets for analyzing backtest results through an extensible visualization system built on Plotly. You can generate reports with minimal code and add custom charts and themes.”

Plotly. Not Bokeh. HTML output. Backtest-focused. Extensible through a chart registry. This is the entire scope of the [visualization] extras - nothing more, nothing less.

The extras package - what’s in the box

Install

uv pip install "nautilus_trader[visualization]"
# or, if you only want the underlying lib:
uv pip install "plotly>=6.3.1"

The extras pull plotly>=6.3.1 as the only added dependency. There is no Bokeh, no Dash, no Streamlit in the package. The doc is explicit: “The visualization system requires plotly>=6.3.1.”

Three structural parts

  1. Chart Registry - Decoupled chart definitions. Built-in charts plus user-registered custom charts. Registry pattern means custom charts can coexist with built-ins under the same TearsheetConfig.charts list.
  2. Theme System - Consistent styling primitives (colors, fonts, backgrounds). Four built-in themes plus register_theme(...) for custom.
  3. Configuration - TearsheetConfig is the declarative top-level: which charts, which theme, which layout, what title, what height, benchmark overlay yes/no.

Output format

“All visualization outputs are self-contained HTML files that can be viewed in any modern browser, shared with stakeholders, or archived for future reference.”

One HTML file. Self-contained (data inline). Open in any browser. No server. PNG export is not surfaced as a first-class flag in create_tearsheet(...), though Plotly’s underlying fig.write_image(...) can be called against the returned figure object if needed (Plotly supports PNG/SVG/PDF export with the kaleido extra).

Tearsheet - the primary artifact

A tearsheet in Nautilus parlance is a single HTML page that combines multiple charts and statistics into one interactive visualization. It is generated after a backtest run, by passing the engine to create_tearsheet(...).

Quick start

from nautilus_trader.analysis import create_tearsheet
from nautilus_trader.backtest.engine import BacktestEngine
 
# After running your backtest
engine.run()
 
# Generate tearsheet
create_tearsheet(
    engine=engine,
    output_path="backtest_results.html",
)

This produces an HTML file with all eight default charts, the light theme, and an automatic 4×2 grid layout. Open the HTML in a browser.

Customized

from nautilus_trader.analysis import (
    TearsheetConfig,
    TearsheetDrawdownChart,
    TearsheetEquityChart,
    TearsheetRunInfoChart,
    TearsheetStatsTableChart,
)
 
config = TearsheetConfig(
    charts=[
        TearsheetRunInfoChart(),
        TearsheetStatsTableChart(),
        TearsheetEquityChart(),
        TearsheetDrawdownChart(),
    ],
    theme="nautilus_dark",
    height=2000,
)
 
create_tearsheet(
    engine=engine,
    output_path="custom_tearsheet.html",
    config=config,
)

Currency filtering

For multi-currency backtests, narrow stats to one currency:

from nautilus_trader.model.currencies import USD
 
create_tearsheet(
    engine=engine,
    output_path="usd_only.html",
    currency=USD,  # None (default) shows all currencies separately
)

Plot taxonomy - the eight built-in charts

Chart nameTypeDescription
run_infoTableRun metadata: ID, start/end, iterations, event/order/position counts, account starting/ending balances per currency.
stats_tableTablePerformance metrics in three sections: PnL Statistics (per currency), Returns Statistics (Sharpe, Sortino, max drawdown), General Statistics (total trades, avg duration).
equityLineCumulative returns over backtest period. Optional benchmark overlay.
drawdownAreaDrawdown percentage from peak equity.
monthly_returnsHeatmapMonthly return percentages organized by year.
distributionHistogramDistribution of individual return values.
rolling_sharpeLine60-day rolling Sharpe ratio.
yearly_returnsBarAnnual return percentages.
bars_with_fillsCandlestickPrice bars (OHLC) with order fills overlaid as markers.

Default grid: when all eight ship at once, a 4×2 grid is auto-laid out with row heights [0.50, 0.22, 0.16, 0.12] - top row gets the most space, which is where run_info and stats_table live by default.

Equity curve with benchmark

import pandas as pd
 
# Index should be datetime, aligned with strategy returns
benchmark_returns = pd.read_csv("sp500_returns.csv", index_col=0, parse_dates=True)["return"]
 
create_tearsheet(
    engine=engine,
    output_path="with_benchmark.html",
    benchmark_returns=benchmark_returns,
    benchmark_name="S&P 500",
)

The benchmark series is plotted as-is. Index alignment is the caller’s responsibility - Nautilus does no resampling.

Theme system

Four built-ins:

Theme nameDescriptionUse case
plotly_whiteClean light theme with dark gray headers.Default, professional reports.
plotly_darkDark background, standard Plotly colors.Low-light environments.
nautilusLight with NautilusTrader brand colors.Official light.
nautilus_darkDark with teal/cyan signature.Official dark.

Selecting a theme:

config = TearsheetConfig(theme="nautilus_dark")

Custom theme registration:

from nautilus_trader.analysis import register_theme
 
register_theme(
    name="cortana",
    template="plotly_dark",
    colors={
        "primary":   "#00ff9c",   # cortana terminal green
        "positive":  "#2ecc71",
        "negative":  "#e74c3c",
        "neutral":   "#95a5a6",
        "background":"#0a0a0a",
        "grid":      "#1a1a1a",
        # Optional table colors (defaults provided if omitted)
        "table_section":  "#1a1a1a",
        "table_row_odd":  "#0a0a0a",
        "table_row_even": "#111111",
        "table_text":     "#00ff9c",
    },
)
 
config = TearsheetConfig(theme="cortana")

Backward compatibility: if table_* colors are omitted, sensible defaults are computed from background and grid, so legacy themes still work.

Configuration - TearsheetConfig parameters

ParameterTypeDefaultDescription
chartslist[TearsheetChart]All built-insChart objects, in order.
themestr"plotly_white"Theme name.
layoutGridLayoutNone (auto)Custom subplot grid.
titlestrAuto-generatedTearsheet title.
include_benchmarkboolTrueShow benchmark when provided.
benchmark_namestr"Benchmark"Display name.
heightint1500Total pixel height.
show_logoboolTrueDisplay NautilusTrader logo (reserved for future use).

Custom layout example:

from nautilus_trader.analysis import GridLayout
 
config = TearsheetConfig(
    charts=[...],
    layout=GridLayout(
        rows=2,
        cols=2,
        heights=[0.60, 0.40],
        vertical_spacing=0.08,
        horizontal_spacing=0.12,
    ),
)

When layout=None, grid dimensions and row heights auto-compute from chart count. For non-default chart sets, expect to set layout explicitly to avoid weird auto-layout behavior.

Customization - adding custom charts

Two registration tiers. The registry pattern is the same shape as Nautilus’s other extension points (similar to register_statistic on PortfolioAnalyzer).

Tier 1: standalone chart (stable API)

from nautilus_trader.analysis.tearsheet import register_chart
import plotly.graph_objects as go
 
def my_custom_chart(returns, output_path=None, title="Custom", theme="plotly_white"):
    from nautilus_trader.analysis.themes import get_theme
    theme_config = get_theme(theme)
 
    fig = go.Figure()
    fig.add_trace(go.Scatter(
        x=returns.index,
        y=returns.cumsum(),
        mode="lines",
        line={"color": theme_config["colors"]["primary"]},
    ))
    fig.update_layout(template=theme_config["template"], title=title)
 
    if output_path:
        fig.write_html(output_path)
    return fig
 
register_chart("my_custom", my_custom_chart)

This is the public API. Charts registered this way are accessible via get_chart() / list_charts() and can be invoked standalone.

Tier 2: tearsheet integration (internal API - use with care)

from nautilus_trader.analysis.tearsheet import _register_tearsheet_chart
 
def _render_my_metric(fig, row, col, returns, theme_config, **kwargs):
    metric = returns.rolling(30).std() * 100
    fig.add_trace(
        go.Scatter(x=returns.index, y=metric, mode="lines"),
        row=row, col=col,
    )
 
_register_tearsheet_chart(
    name="volatility",
    subplot_type="scatter",
    title="Rolling Volatility (30-day)",
    renderer=_render_my_metric,
)
 
# Now usable in TearsheetConfig.charts
config = TearsheetConfig(
    charts=[
        TearsheetStatsTableChart(),
        TearsheetEquityChart(),
        TearsheetCustomChart(chart="volatility"),
    ],
)

The doc is explicit about the stability boundary:

“The _register_tearsheet_chart function is internal API and may change between releases. For most use cases, prefer register_chart for standalone charts or contribute new built-in charts upstream.”

For Cortana: anything we add for ourselves should use register_chart. Anything that touches the tearsheet grid layout is internal API and must be pinned to a specific Nautilus version.

Custom statistics

Custom charts pair naturally with custom PortfolioStatistic registrations (see Nautilus Portfolio):

from nautilus_trader.analysis.statistic import PortfolioStatistic
 
class MyCustomStatistic(PortfolioStatistic):
    def calculate_from_returns(self, returns):
        return ...
 
analyzer.register_statistic(MyCustomStatistic())
# Now appears in stats_returns for any custom chart that consumes it.

Output formats

FormatHow
HTML (interactive, self-contained)Default. create_tearsheet(engine=..., output_path="x.html").
HTML in JupyterThe returned figure renders inline in notebooks (Plotly’s standard _repr_html_).
PNG / SVG / PDFNot first-class. Call fig.write_image(...) on the returned figure (requires kaleido).
JSONPlotly figures serialize to JSON via fig.to_json() if you need a programmatic surface.

The “HTML files contain all data inline and can be several megabytes for long backtests” caveat is real. For multi-month / multi-year SPY 0DTE replays, expect 5-50 MB per tearsheet. Sharing via Slack/email is fine; storing 1000 of them per tenant is wasteful. Generate per-run, archive selectively.

Offline analysis - precomputed stats path

For analyzing results without a BacktestEngine instance:

from nautilus_trader.analysis.tearsheet import create_tearsheet_from_stats
import pandas as pd
 
stats_pnls = {"USD": {"PnL (total)": 1500.0, "Win Rate": 0.55, ...}}
stats_returns = {"Sharpe Ratio (252 days)": 1.2, "Max Drawdown": -0.15, ...}
stats_general = {"Avg Winner": 100.0, "Avg Loser": -50.0, ...}
returns = pd.Series(...)  # Daily returns
 
create_tearsheet_from_stats(
    stats_pnls=stats_pnls,
    stats_returns=stats_returns,
    stats_general=stats_general,
    returns=returns,
    output_path="offline.html",
)

The dictionary keys must match PortfolioAnalyzer.get_performance_stats_*() output (see Nautilus Portfolio). This is the right path for:

  • Comparing N stored backtests side-by-side.
  • Integrating with external pipelines (write stats to a DB, render later).
  • A/B comparing strategy variants without re-running each engine.

Cortana MK3 implications

The MK3 roadmap defines three visualization surfaces. The extras package covers exactly one of them.

Surface 1 - preserved MK2 operator dashboard (live ops)

Source: Nautilus cache via Python API (read-only). Use case: today’s trades, open positions, current signal scores, score breakdowns, in-flight order state. Plotly tearsheets do NOT serve this surface. Tearsheets are post-run artifacts; the operator dashboard is live. The repoint is straightforward in shape (self.cache.positions_open(), self.cache.orders_open(), self.cache.quote_tick(instrument_id), etc. - see Nautilus Cache) but is not what nautilus_trader[visualization] provides. The dashboard preservation is correct architectural framing; the extras package is irrelevant to it.

Surface 2 - backtest tearsheets (research artifacts)

Source: nautilus_trader[visualization] extras after each engine.run(). Use case: equity curves, sharpe, drawdown, fill quality, return distributions across replay periods. This is exactly what the extras package provides out of the box. Six of the eight built-in charts (equity, drawdown, monthly_returns, distribution, rolling_sharpe, yearly_returns) cover the core research questions. bars_with_fills covers fill-quality visualization. stats_table covers Brier/AUC-class metrics if we register custom PortfolioStatistics for them. The MK3 roadmap’s “Phase 1 expectancy harness becomes Nautilus tearsheet customization in M2” is achievable without writing chart code.

Surface 3 - ML Dashboard #81 (per-tenant ML transparency)

Source: Nautilus event stream + ML model artifacts. Use case: kNN neighbors per signal, feature health, calibration curves, per-tenant ML transparency. Hand-built. Not served by extras. ML Dashboard #81 is interactive, live, per-tenant, and queries against the meta-model - it’s a different beast than a static post-backtest HTML.

The actual question - does extras-cover-equity-and-distribution mean we don’t need them in #81?

No, but the answer is more nuanced than “no.”

ML Dashboard #81 wants equity / drawdown / return-distribution panels embedded in a live, per-tenant, ML-aware UI. Nautilus extras give us those panels as standalone HTML tearsheets, not as embeddable React/JS components. Three implications:

  1. For research / postmortem use: extras is sufficient. We open the HTML. Done. Don’t rebuild.
  2. For live operator viewing inside ML Dashboard #81: extras is not sufficient as-is. The HTML output is monolithic; the ML Dashboard wants per-position drill-downs, per-feature live updates, per-tenant scoping.
  3. The pragmatic middle path: ML Dashboard #81 can embed Plotly-figure JSON (via fig.to_json() on the same Plotly figures the tearsheet code produces), then re-render client-side using plotly.js. That re-uses the chart logic without rebuilding it - but it requires ML Dashboard’s frontend to ship plotly.js (~3 MB) which is a real bundle-size question.

Recommended posture: rely on extras for surface #2 (backtest research). Treat ML Dashboard #81 surface #3 as separate hand-built work, but lift the chart logic (returns calculation, drawdown calculation, rolling-sharpe calculation) from PortfolioAnalyzer rather than re-implementing it. Do not assume the extras HTML drops into ML Dashboard #81 unchanged.

Is the dashboard repoint at Nautilus cache straightforward?

Yes, with two caveats.

The shape is straightforward - every panel in the MK2 dashboard can be expressed as a Nautilus cache query. self.cache.positions_open(), self.cache.orders_open(), self.cache.quote_tick(instrument_id), self.cache.account(account_id). See Nautilus Cache for the full read surface. The cache is exactly the right shape for what the dashboard needs.

Caveat 1 - process boundary. The dashboard is a separate Node/Python process today, querying a SQLite view. The Nautilus cache lives inside the TradingNode process. Two paths: (a) embed dashboard server inside the trading-node process (couples them - bad); (b) externalize cache via Redis (DatabaseConfig) and have dashboard process read Redis directly (good, already the recommended pattern for live deployments per Nautilus Cache). The roadmap’s “Dockerized IB Gateway adopted (per task #96)” decision aligns naturally with externalized cache.

Caveat 2 - the bridge. During M1 the spike plan calls out an intermediate “local dashboard sidecar that mirrors Nautilus events to SQLite for the existing MK2 dashboard to read (Option B from spike planning - temporary until M2 reuses the dashboard cleanly).” That’s the right shape. The full repoint at Redis-backed cache lands in M3 with the production cutover.

So: straightforward in principle, with operational caveats around process boundaries and the M1→M3 bridge.

Best practices

From the doc, with Cortana-specific gloss:

Chart selection

  • Default for exploratory analysis - cheap, all metrics visible.
  • Customize when ready - drop charts that don’t matter for the strategy.
  • Remove irrelevant charts - reduces visual clutter and HTML file size. For SPY 0DTE specifically: monthly_returns heatmap is uninteresting (we only trade weekdays during market hours, no monthly seasonality signal at this granularity); yearly_returns is uninteresting until we have multi-year data.

Theme usage

  • plotly_white for professional reports.
  • nautilus_dark for low-light viewing.
  • Custom Cortana theme for branded outputs (terminal green on black, matching the existing dashboard).

Performance

  • Tearsheets contain all data inline; can be several MB per run.
  • For long replays, generate separate tearsheets for different timeframes (e.g., one per week of replay, one per month).
  • For very large datasets, use individual chart functions standalone rather than full tearsheets.

Custom statistics integration

“Custom charts work best when paired with custom statistics registered in the PortfolioAnalyzer. This ensures your visualizations display metrics computed consistently with the rest of the system.”

For Cortana: register Brier-score, AUC, hit-rate-at-confidence-threshold as PortfolioStatistics (see Nautilus Portfolio), THEN render them via the extras tearsheet. Do not compute them in chart code - keeps the metric implementation in one place.

Anti-patterns

  • Treating extras tearsheets as a live dashboard surface. They’re not. They’re post-run artifacts. Live ops belongs to the preserved MK2 dashboard reading the cache.
  • Embedding the entire HTML tearsheet in ML Dashboard #81 via iframe. Works mechanically, terrible UX, no per-tenant scoping, no live updates. Lift the figure JSON instead.
  • Forking the chart code into ML Dashboard #81. Don’t reimplement drawdown/rolling-sharpe - use PortfolioAnalyzer as the single source of truth for return metrics, then render client-side.
  • Pinning to _register_tearsheet_chart. Internal API. Pinned Nautilus version required if used. Prefer register_chart standalone.
  • Assuming PNG/SVG export is first-class. It isn’t. kaleido is required for fig.write_image(...). If we want PDF reports of monthly research, install kaleido separately.
  • Ignoring index alignment for benchmark comparisons. Nautilus does no resampling. Garbage in, garbage on the chart.

Open questions for the 2026-05-09 spike

  1. Plotly version pin. Extras requires plotly>=6.3.1. Does this conflict with anything we currently ship? (Check pyproject.toml.)
  2. Tearsheet HTML size on a 4-week SPY 0DTE replay. Concrete data point needed to decide whether to archive every run or just key ones.
  3. Custom theme parity with existing dashboard. Can we hit the exact terminal-green look, or do we accept “close enough” and reserve pixel-perfect for the live dashboard?
  4. Embedding Plotly figure JSON in ML Dashboard #81. Bundle-size question: can ML Dashboard frontend afford plotly.js?
  5. Multi-tenant tearsheet generation. Per-tenant per-run output paths (tenants/{id}/runs/{ts}/tearsheet.html)? Where do they live in M5 web app? Probably S3/CDN, not embedded - but this is M5 territory.

See Also

  • Nautilus Concepts - full concept canon parent
  • Nautilus Portfolio - parallel; the PortfolioAnalyzer produces the stats that tearsheets render
  • Nautilus Reports - parallel; programmatic reporting surface; tearsheets are the rendered counterpart
  • Nautilus Cache - what the live dashboard surface reads (different from tearsheets)
  • Nautilus Tutorials - backtest tutorials that produce the engines tearsheets render from
  • Spike plan - Step 7 dashboard repoint feasibility: ~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-nautilus-spike.md
  • MK3 roadmap - visualization stack table near the bottom: ~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-mk3-roadmap.md
  • ML Dashboard #81 - per-tenant ML transparency view (separate surface)

Timeline

2026-05-07 | Cody - Filed during pre-spike concept mastery sweep batch 3.