Lesson 0.6.1 · 45 min

Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts

A trading chart answers a question: how much it has gained, how much it has lost at worst, how the days are distributed. This lesson builds the three charts that recur most in the course with the three most used libraries, plus the rules that keep them from misleading.

Fig. — SINT, 2021–2025: weekly close on a log scale and its drawdown below. The same series as the Matplotlib cell, drawn with Vega
01/02
Module
0.6 · Visualisation
Duration
45 min
Updated
Level 0 lessons
  1. 0.1 · Welcome and expectations

    1. 0.1.1 Systematic, algorithmic and quantitative trading versus discretionary trading
    2. 0.1.2 Realistic expectations: costs, time and risk of loss
    3. 0.1.3 Course structure, entry tests, personal repository and learning with AI
  2. 0.2 · Modern working environment

    1. 0.2.1 Installing Python with uv: versions and support cycle
    2. 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
    3. 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
    4. 0.2.4 Basic terminal, paths, environment variables and .env files
    5. 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
    6. 0.2.6 Introduction to AI coding assistants: prompts, review and tests
  3. 0.3 · Language fundamentals

    1. 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
    2. 0.3.2 Strings, f-strings and formatting numbers and dates
    3. 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
    4. 0.3.4 Control flow: if, elif, else, for, while and match-case
    5. 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
    6. 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
  4. 0.4 · Intermediate Python

    1. 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
    2. 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
    3. 0.4.3 Iterators, generators, decorators and context managers
    4. 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
    5. 0.4.5 Professional logging and testing with pytest
    6. 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
  5. 0.5 · Numerical computing and data

    1. 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
    2. 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
    3. 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
    4. 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
    5. 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
  6. 0.6 · Visualisation

    1. 0.6.1 Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts
    2. 0.6.2 Quick dashboards with Streamlit: a monitoring dashboard in a single script
  7. 0.7 · Minimum mathematics and statistics

    1. 0.7.1 Simple and log returns, compound interest and descriptive statistics
    2. 0.7.2 Probability and distributions, correlation versus causation and simple regression
    3. 0.7.3 Intuitive linear algebra: vectors and matrices as groundwork for portfolios

One chart, one question

A trading chart is not decoration: it is the fastest way to answer a specific question about a series. Before writing code, it pays to know which question it is, because the question decides the chart type, the scale and what is superfluous.

QuestionChartUsual library
How much has it gained or lost over time?Price or equity line, logarithmic scaleMatplotlib
How much has it lost at worst from a peak?Drawdown as an area, below the equity curveMatplotlib
How are daily returns distributed?HistogramSeaborn
Which period was most volatile?Bars by year or rolling volatility lineSeaborn, Matplotlib
What did the price do within each session?Candlesticks (OHLC)Plotly, mplfinance

The three libraries do not compete: they complement each other. Matplotlib is the base, the one that gives the most control and the one almost all others use under the hood. Seaborn builds on Matplotlib and produces statistical charts in one line. Plotly draws in the browser, with zoom and cursor, and is the natural choice for exploring candlesticks or building a dashboard (lesson 0.6.2).

The data is the usual: the daily prices of SINT, the course's fictitious ETF. Reading it with pandas was covered in lesson 0.5.2; here we also compute the drawdown, the drop from the peak reached up to that day:

IN [01]python
import pandas as pd

precios = pd.read_csv("sint_diario.csv", parse_dates=["fecha"],
                      index_col="fecha")
cierre = precios["cierre"]
drawdown = cierre / cierre.cummax() - 1
print(f"Sessions: {len(cierre)}")
print(f"Maximum drawdown: {drawdown.min():.1%} "
      f"on {drawdown.idxmin():%Y-%m-%d}")
OUT [01]SINT prices and drawdown
Sessions: 1303
Maximum drawdown: -25.0% on 2022-10-20

cummax() carries the running maximum up to each day; dividing the close by it and subtracting 1 gives the distance to that peak, always negative or zero. SINT's worst moment is a 25.0 % drop in October 2022.

Matplotlib: figures and axes

Matplotlib can be used in two ways. The implicit one (plt.plot(…), plt.ylabel(…)) draws on "the current axes", a global state that pyplot keeps behind the scenes. It is convenient for a quick chart, but with two panels it is no longer clear which instruction goes to which. The explicit or object-oriented one creates the objects and names them:

  • Figure: the whole canvas, with its size and the way it is saved.
  • Axes: each panel inside the figure, with its X and Y axes, its title and its series. A price chart with the drawdown below is a figure with two Axes.

plt.subplots() returns both at once. From there on, everything is done with each panel's methods:

IN [02]python
import matplotlib.pyplot as plt
from matplotlib.ticker import PercentFormatter

fig, (ax_precio, ax_dd) = plt.subplots(
    2, 1, sharex=True, figsize=(10, 6),
    height_ratios=(3, 1), layout="constrained",
)
ax_precio.plot(cierre.index, cierre, linewidth=1)
ax_precio.set_yscale("log")
ax_precio.set_ylabel("Close (log scale)")
ax_precio.set_title("SINT · daily close 2021-2025")
ax_dd.fill_between(drawdown.index, drawdown, 0, alpha=0.4)
ax_dd.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=0))
ax_dd.set_ylabel("Drawdown")
fig.axes
OUT [02]Price and drawdown in two panels
[<Axes: title={'center': 'SINT · daily close 2021-2025'}, ylabel='Close (log scale)'>, <Axes: ylabel='Drawdown'>]

The highlighted lines do almost all the work: two rows and one column (2, 1), the shared X axis (sharex=True: when you zoom in one panel, the other follows), the price panel three times taller than the drawdown panel and layout="constrained" so the labels do not overlap. The last line asks the figure for the list of its panels: two Axes objects, each with its title and label.

The figure at the top shows the result with the same data, drawn with Vega for the web (weekly closes and the worst drawdown of each week, to keep it light). It is read from top to bottom: the 2022 decline is seen in the price and measured in the panel below.

Two decisions in this figure recur throughout the course:

  • Logarithmic scale on the price. On a linear scale, a rise from 50 to 100 takes up the same height as one from 100 to 150, even though the first is 100 % and the second 50 %. On a logarithmic scale, the same percentage change takes up the same height at any level. With SINT, which moves between 95 and 150, the difference is small; with a twenty-year index that multiplies its value by ten, it is the difference between seeing the old drops or not seeing them.
  • The drawdown below, not above. The price curve says how much was gained; the drawdown, how much was suffered along the way. With the shared X axis you see at once when the decline started, how long it lasted and when the peak was recovered.

Saving the figure is another method of the object. dpi sets the PNG resolution:

IN [03]python
from pathlib import Path

fig.savefig("sint_drawdown.png", dpi=150)
Path("sint_drawdown.png").exists()
OUT [03]Save the figure
True

In a notebook the figure appears on its own at the end of the cell; in a script you need plt.show() to open the window, or just savefig() if the chart is for a report. To give all the charts in your repository the same look, a style file (plt.style.use("mi_estilo.mplstyle")) sets colors, fonts and grid once.

Seaborn: distributions in one line

Seaborn works on top of Matplotlib and thinks in data, not in lines: you pass it a series or a DataFrame and it picks the right statistical chart. The histogram of daily returns (what fraction of days goes up or down by how much) is the most useful statistical chart in trading:

IN [04]python
import seaborn as sns

rent = cierre.pct_change().dropna()
fig, ax = plt.subplots(figsize=(8, 4))
sns.histplot(rent, bins=60, stat="density", ax=ax)
ax.xaxis.set_major_formatter(PercentFormatter(1.0, decimals=0))
ax.set_xlabel("Daily return")
print(f"Days: {len(rent)} · bars: {len(ax.patches)}")
OUT [04]Histogram of returns
Days: 1302 · bars: 60

Two details. The first, ax=ax: Seaborn draws on the axes it is given, just like Matplotlib. Without that argument it would use pyplot's "current axes", and with several charts in the same notebook that ends badly. The second, stat="density": the height of each bar is normalized so the total area is 1, so the histogram can be compared with a density curve. That chart, with the normal distribution overlaid, is in lesson 0.7.1, which is where its shape is interpreted.

The second typical question is by category: which year was most volatile? The annualized volatility (the daily standard deviation multiplied by 252\sqrt{252}, lesson 0.7.1) is computed with a groupby by year and Seaborn draws it as bars:

IN [05]python
import numpy as np

vol_anual = rent.groupby(rent.index.year).std() * np.sqrt(252)
fig, ax = plt.subplots(figsize=(6, 3))
sns.barplot(x=vol_anual.index, y=vol_anual.values, color="0.6",
            ax=ax)
ax.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=0))
ax.set_ylabel("Annualized volatility")
vol_anual.round(3)
OUT [05]Volatility by year
fecha
2021    0.160
2022    0.248
2023    0.149
2024    0.150
2025    0.179
Name: cierre, dtype: float64

The year 2022, at 24.8 %, stands out over a baseline of 15–16 %: it is the stress episode that also shows in the drawdown. 2025 rises to 17.9 % because of a second, shorter episode in the spring. One bar per year and a single color: the comparison is the length, and nothing else competes for attention.

Candlesticks with Plotly

A candlestick summarizes a session with four prices: open, high, low and close (OHLC). The body goes from the open to the close; the wick, from the low to the high. If the close is above the open, the candle is bullish; if it is below, bearish. With sixty sessions you see the structure of the last three months; with five years, the candles turn into a smudge and a line is worth more.

Plotly builds the figure as an object (go.Figure) with one or more traces. The candlestick trace is go.Candlestick:

IN [06]python
import plotly.graph_objects as go

ult = precios.tail(60)
fig = go.Figure(go.Candlestick(
    x=ult.index, open=ult["apertura"], high=ult["maximo"],
    low=ult["minimo"], close=ult["cierre"], name="SINT",
))
fig.update_layout(template="plotly_dark", height=450,
                  xaxis_rangeslider_visible=False,
                  title="SINT · last 60 sessions")
fig.update_xaxes(rangebreaks=[dict(bounds=["sat", "mon"])])
fig.show()

fig.show() opens the chart in the browser or renders it in the notebook, with zoom, panning and a cursor that shows the four prices of each candle. xaxis_rangeslider_visible=False removes the range slider that Plotly adds by default below the candles. rangebreaks removes the weekends: with a continuous date axis, Plotly would leave a gap every Saturday and Sunday. The holidays of a real market (SINT has none) are removed the same way, with dict(values=[…]) and the list of dates.

These are the data just plotted, drawn with Vega:

Fig. — SINT daily candles, last 60 sessions of 2025 (blue, close above the open; orange, below). The same data as the Plotly chart, drawn with Vega

To share the chart without Python, save it as HTML. With include_plotlyjs="cdn" the file weighs a few KB and loads the library from the Internet when opened; without that argument it carries the library inside and works offline:

IN [07]python
alcistas = int((ult["cierre"] >= ult["apertura"]).sum())
print(f"Candles: {len(fig.data[0].x)} · bullish: {alcistas} · "
      f"bearish: {len(ult) - alcistas}")
print(f"From {ult.index[0]:%Y-%m-%d} to {ult.index[-1]:%Y-%m-%d}")
fig.write_html("velas_sint.html", include_plotlyjs="cdn")
OUT [07]Check and save the chart
Candles: 60 · bullish: 29 · bearish: 31
From 2025-10-09 to 2025-12-31

The Matplotlib alternative is mplfinance, a library designed only for price charts: candlesticks, volume and moving averages in one call. It requires columns with English names (Open, High, Low, Close, Volume) and a date index. It is not part of the course environment; if you install it (uv add mplfinance), the equivalent is:

IN [08]velas_mplfinance.pypython
import mplfinance as mpf

ohlc = ult.rename(columns={
    "apertura": "Open", "maximo": "High", "minimo": "Low",
    "cierre": "Close", "volumen": "Volume",
})
mpf.plot(ohlc, type="candle", volume=True, style="nightclouds")

mplfinance generates static images, useful for a PDF report; Plotly, interactive charts, better for exploring. Neither turns a candlestick chart into a signal: the "patterns" seen in them are, until proven otherwise with data (level 2), shapes the eye finds in the noise.

Charts that do not mislead

A badly built chart can make a mediocre strategy look good. These rules apply to all the charts in the course:

  • Logarithmic scale on long series. Prices and equity curves of more than a couple of years, always logarithmic. On a linear scale, the end of the series dominates and the early drops disappear.
  • The drawdown, below the equity curve. An equity curve without its drawdown hides what was suffered to achieve it.
  • The full period, and stated. Starting the chart right after a drop is the cheapest way to improve a result. The title or the caption says from which date to which date.
  • Units on the axes. Euros, percent, basis points: an axis without units cannot be read.
  • Comparisons on the same scale. Two strategies in the same panel and with the same Y axis; two Y axes on the same chart let you make two series cross wherever it suits you.
  • No pie charts or 3D. The eye compares lengths much better than angles or volumes. The weight of each asset in a portfolio reads better in sorted horizontal bars than in a pie, and 3D only adds distortion.
  • Colors everyone can tell apart. Blue for what goes up and orange for what goes down, as in this course's figures, instead of green and red, which some readers cannot distinguish.

Exercises

  1. Add a 200-session moving average to the price panel and a horizontal line at −10 % to the drawdown panel. Check how many panels the figure has and how many lines the price panel has.
Solution
IN [09]python
fig, (ax_precio, ax_dd) = plt.subplots(
    2, 1, sharex=True, figsize=(10, 6), height_ratios=(3, 1),
)
ax_precio.plot(cierre.index, cierre, linewidth=1, label="Close")
media = cierre.rolling(200).mean()
ax_precio.plot(media.index, media, linewidth=1, label="200-session average")
ax_precio.set_yscale("log")
ax_precio.legend()
ax_dd.fill_between(drawdown.index, drawdown, 0, alpha=0.4)
ax_dd.axhline(-0.10, linestyle="--", linewidth=1)
print(f"Panels: {len(fig.axes)}")
print(f"Lines in the price panel: {len(ax_precio.lines)}")
OUT [09]200-session average and drawdown threshold
Panels: 2
Lines in the price panel: 2

axhline draws a horizontal line across the whole panel, whatever the date range. The legend (label= and ax.legend()) is only needed when there is more than one series in the same panel.

  1. Check numerically why the logarithmic scale is used: compute the height, on a logarithmic scale, of a rise from 50 to 100 and of one from 100 to 200. And on a linear scale?
Solution
IN [10]python
for desde, hasta in ((50, 100), (100, 200)):
    log_alto = np.log10(hasta) - np.log10(desde)
    print(f"{desde} → {hasta}: linear {hasta - desde}, "
          f"log {log_alto:.3f}")
OUT [10]Same change, same height
50 → 100: linear 50, log 0.301
100 → 200: linear 100, log 0.301

Both rises are 100 % and on a logarithmic scale they take up exactly the same height (log10(2) ≈ 0.301). On a linear scale, the second takes up twice as much: a linear chart exaggerates the recent part if the price has risen.

  1. Add the volume below the candlesticks with make_subplots (two rows, shared X axis, the candlestick row three times taller). How many traces does the figure have?
Solution
IN [11]python
from plotly.subplots import make_subplots

fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
                    row_heights=[0.75, 0.25], vertical_spacing=0.03)
fig.add_trace(go.Candlestick(
    x=ult.index, open=ult["apertura"], high=ult["maximo"],
    low=ult["minimo"], close=ult["cierre"], name="SINT",
), row=1, col=1)
fig.add_trace(go.Bar(x=ult.index, y=ult["volumen"],
                     name="Volume"), row=2, col=1)
fig.update_layout(template="plotly_dark",
                  xaxis_rangeslider_visible=False)
fig.update_xaxes(rangebreaks=[dict(bounds=["sat", "mon"])])
len(fig.data)
OUT [11]Candlesticks and volume
2

Two traces, one per panel. update_xaxes without row arguments applies to all X axes, so both panels lose the weekend gaps.

Each answer is folded under its question

Self-assessment

  1. In Matplotlib's object-oriented API, what is ax?

    • AThe window where the chart is shown
    • BThe X axis of the chart
    • CAn Axes object: a plotting area with its own axes, title and series, inside a Figure
    • DAn alias of matplotlib.pyplot
    Show answer

    Answer C. An Axes object: a plotting area with its own axes, title and series, inside a Figure

    The Figure is the canvas; each Axes is a panel with its axes. A figure can have several (price and drawdown), and each method (plot, set_yscale, set_ylabel) is called on the right panel, without depending on which one is "current".

  2. A price series goes from 20 to 400 in fifteen years. Which Y-axis scale lets you compare a 30 % drop in year 2 with another 30 % drop in year 14?

    • ALinear, because it is the most honest
    • BAny of them, the chart is the same
    • CA scale that starts at zero
    • DLogarithmic, because the same percentage change takes up the same height at any price level
    Show answer

    Answer D. Logarithmic, because the same percentage change takes up the same height at any price level

    On a linear scale, the drop at price 20 would be almost invisible and the one at price 400 would look like a crash. On a logarithmic scale both take up the same height, which is what matters for capital.

  3. Why does the lesson's Plotly candlestick chart use rangebreaks=[dict(bounds=["sat", "mon"])]?

    • ASo the X axis does not leave an empty gap every weekend, when there is no session
    • BTo remove holidays
    • CTo change the time zone
    • DTo make the chart lighter
    Show answer

    Answer A. So the X axis does not leave an empty gap every weekend, when there is no session

    With a continuous date axis, Plotly reserves space for Saturdays and Sundays. rangebreaks removes them; the holidays of a real market would have to be added with values=[…].

  4. Which of these charts is the most suitable for comparing the weights of four assets in a portfolio?

    • AA 3D pie chart
    • BA sorted horizontal bar chart
    • CA line chart
    • DA candlestick chart
    Show answer

    Answer B. A sorted horizontal bar chart

    The eye compares lengths much better than angles or volumes. Pie charts and 3D distort proportions; sorted bars are read at a glance.

Key points

  • With the object-oriented API every chart is a Figure with one or more Axes; they are created with plt.subplots() and modified by calling each panel's methods.
  • A long price series is drawn on a logarithmic scale, and the drawdown goes below, in a panel that shares the date axis.
  • Seaborn draws distributions and comparisons by category in one line; pass it axes of your own (ax=) so it does not depend on global state.
  • Plotly gives interactive charts (zoom, cursor) and candlesticks with go.Candlestick; fig.write_html() saves them to a file that opens in any browser.
  • No pie charts, no 3D and no two crossing Y axes: each chart, one question, with its units and its period.

Resources