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.
- Module
- 0.6 · Visualisation
- Duration
- 45 min
- Prerequisites
- 0.5.2 · pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- Author
- Jesús Cuesta
- Updated
Level 0 lessons
0.1 · Welcome and expectations
0.2 · Modern working environment
- 0.2.1 Installing Python with uv: versions and support cycle
- 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
- 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Basic terminal, paths, environment variables and .env files
- 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
- 0.2.6 Introduction to AI coding assistants: prompts, review and tests
0.3 · Language fundamentals
- 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
- 0.3.2 Strings, f-strings and formatting numbers and dates
- 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
- 0.3.4 Control flow: if, elif, else, for, while and match-case
- 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
- 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
0.4 · Intermediate Python
- 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
- 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
- 0.4.3 Iterators, generators, decorators and context managers
- 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
- 0.4.5 Professional logging and testing with pytest
- 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
0.5 · Numerical computing and data
- 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
- 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
- 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
0.6 · Visualisation
0.7 · Minimum mathematics and statistics
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.
| Question | Chart | Usual library |
|---|---|---|
| How much has it gained or lost over time? | Price or equity line, logarithmic scale | Matplotlib |
| How much has it lost at worst from a peak? | Drawdown as an area, below the equity curve | Matplotlib |
| How are daily returns distributed? | Histogram | Seaborn |
| Which period was most volatile? | Bars by year or rolling volatility line | Seaborn, 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:
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}")Sessions: 1303
Maximum drawdown: -25.0% on 2022-10-20cummax() 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 twoAxes.
plt.subplots() returns both at once. From there on, everything is done with each panel's methods:
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[<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:
from pathlib import Path
fig.savefig("sint_drawdown.png", dpi=150)
Path("sint_drawdown.png").exists()TrueIn 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:
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)}")Days: 1302 · bars: 60Two 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 , lesson 0.7.1) is computed with a groupby by year and Seaborn draws it
as bars:
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)fecha
2021 0.160
2022 0.248
2023 0.149
2024 0.150
2025 0.179
Name: cierre, dtype: float64The 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:
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:
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:
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")Candles: 60 · bullish: 29 · bearish: 31
From 2025-10-09 to 2025-12-31The 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:
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
- 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
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)}")Panels: 2
Lines in the price panel: 2axhline 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.
- 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
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}")50 → 100: linear 50, log 0.301
100 → 200: linear 100, log 0.301Both 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.
- 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
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)2Two 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
In Matplotlib's object-oriented API, what is
ax?- AThe window where the chart is shown
- BThe X axis of the chart
- CAn
Axesobject: a plotting area with its own axes, title and series, inside aFigure - DAn alias of
matplotlib.pyplot
Show answer
Answer C. An
Axesobject: a plotting area with its own axes, title and series, inside aFigureThe
Figureis the canvas; eachAxesis 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".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.
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.
rangebreaksremoves them; the holidays of a real market would have to be added withvalues=[…].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
Figurewith one or moreAxes; they are created withplt.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.