Lesson 0.6.2 · 45 min
Quick dashboards with Streamlit: a monitoring dashboard in a single script
A notebook is for research; a dashboard, for looking at the same thing every day without touching code. Streamlit turns a Python script into a web page with selectors and charts. This lesson builds a price and drawdown dashboard on the course data and explains the one idea you need to understand to avoid fighting it: the whole script runs on every click.
- Module
- 0.6 · Visualisation
- Duration
- 45 min
- Prerequisites
- 0.6.1 · Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts
- 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
From notebook to dashboard
In lesson 0.6.1 the charts were made in a notebook: you change a line, run the cell and look at the result. For research that is the right approach. For monitoring a portfolio every morning it is not: nobody wants to edit code to change the asset or the period, let alone ask a colleague who does not program to do it.
A dashboard solves that: a page with a few controls (asset, dates) and always the same figures and charts.
Streamlit builds that page from an ordinary Python script, with no HTML or JavaScript. Each st.something(…) in the
script adds an element to the page, in the order in which it appears.
| Tool | What it is for | Who uses it |
|---|---|---|
| Notebook (Jupyter, Marimo) | Research, testing ideas, documenting an analysis | Whoever writes the code |
Script with savefig() | Fixed reports that generate themselves (PDF, PNG) | Anyone, without interaction |
| Streamlit dashboard | Daily monitoring with filters: asset, period, strategy | Anyone, with interaction |
| Custom web application | Product with users, permissions and heavy load | A development team |
Streamlit fills the gap between the notebook and the custom application: a hundred lines give you a useful dashboard. In exchange, the way it runs is different from that of an ordinary script, and almost all the problems of a dashboard that is slow or "forgets" its data come from not understanding it.
Streamlit is not part of the course environment: the interface code is shown but not run on this page. What does run, and it is the important part, are the data functions the dashboard uses.
The whole script, on every click
An ordinary script runs once, from the first line to the last, and ends. A Streamlit app runs whole, from top to bottom, every time the user touches a widget (and every time you save the file while developing it). When the user changes the asset in a selector, Streamlit does not run "the selector part": it relaunches the whole script, and this time the selector line returns the new asset.
Mermaid source
flowchart TB
U[Touches a widget] --> S[Whole script]
S --> W[Reads the widgets]
W --> C{Cached function}
C -->|already computed| K[Copy from cache]
C -->|new| F[Runs and stores]
K --> P[Draws the page]
F --> P
P --> UThree practical consequences follow from that rule:
- The expensive parts repeat if not cached. Reading a five-year CSV takes little time; downloading prices from a provider or reading a Parquet file of several gigabytes does not. Without a cache, every click repeats it.
- Variables do not survive between runs. A counter
n = n + 1starts over on every click, because the script starts again. Whatever must be remembered between clicks (a list of orders the user keeps adding) goes inst.session_state, which this dashboard does not need. - The order of the script is the order of the page. There are no callbacks to order or events to wire: it reads from top to bottom, like the notebook it comes from.
The data functions, without Streamlit
The most useful design rule for a dashboard: the interface computes nothing. The functions that read data, trim
periods and compute metrics go in their own module, panel_datos.py, which does not import Streamlit. That way they are
tested like any function (lesson 0.4.5), reused in a notebook or a report, and app.py remains a thin layer that only
draws.
The dashboard works with five fictitious assets from the course: SINT and the four in cartera_sint.csv. The first
function puts their closes together in a table with one column per asset:
import numpy as np
import pandas as pd
def cargar_cierres() -> pd.DataFrame:
"""Daily closes: one column per asset, date index."""
sint = pd.read_csv("sint_diario.csv", parse_dates=["fecha"],
index_col="fecha")["cierre"]
cartera = pd.read_csv("cartera_sint.csv",
parse_dates=["fecha"], index_col="fecha")
return cartera.join(sint.rename("SINT"), how="inner")
cierres = cargar_cierres()
print(cierres.shape)
cierres.tail(2)(1303, 5)
RVAR RFIJ ORO TECH SINT
fecha
2025-12-30 165.47 102.30 170.50 78.25 139.98
2025-12-31 164.35 102.29 171.44 77.82 142.22how="inner" keeps only the dates present in both files. Here they all match, but with real data from two markets with
different holidays (a European and an American exchange) that would not happen, and it is better for the function to
handle it explicitly than to find gaps in the chart.
The second function trims an asset to a period. The detail is in the type of the dates: Streamlit's date selector
returns datetime.date objects, while the pandas index is made of Timestamp. Slicing a date index with
datetime.date is deprecated in pandas 3 (it emits a warning and will stop working), so the function converts before
slicing:
import datetime as dt
def tramo(cierres: pd.DataFrame, ticker: str,
desde: dt.date, hasta: dt.date) -> pd.Series:
"""Closes of one asset between two dates, both included."""
inicio, fin = pd.Timestamp(desde), pd.Timestamp(hasta)
return cierres.loc[inicio:fin, ticker].dropna()
serie = tramo(cierres, "TECH", dt.date(2025, 1, 1),
dt.date(2025, 12, 31))
print(f"{serie.name}: {len(serie)} sessions, "
f"from {serie.index[0]:%Y-%m-%d} to {serie.index[-1]:%Y-%m-%d}")TECH: 261 sessions, from 2025-01-01 to 2025-12-31The 261 sessions include January 1: SINT and the course portfolio have no holidays. With data from a real market, the first day of the period would be the first session of the year, and the function does not need to know that.
The third computes the figures the dashboard shows at the top: period return, annualized volatility (the daily standard
deviation times ; the reason is in lesson 0.7.1) and maximum drawdown. A period with fewer than two
sessions has none of the three, and the function says so with a clear error instead of returning NaN:
def metricas(cierre: pd.Series) -> dict[str, float]:
"""Return, annual volatility and maximum drawdown."""
if len(cierre) < 2:
raise ValueError("At least two sessions are needed")
rent = cierre.pct_change().dropna()
drawdown = cierre / cierre.cummax() - 1
return {
"rentabilidad": cierre.iloc[-1] / cierre.iloc[0] - 1,
"volatilidad": rent.std() * np.sqrt(252),
"drawdown_max": drawdown.min(),
}
for nombre, valor in metricas(serie).items():
print(f"{nombre:<13}{valor:8.2%}")rentabilidad -9.25%
volatilidad 28.82%
drawdown_max -31.91%TECH ends 2025 with a loss of 9.25 %, but along the way it lost as much as 31.91 %: the drawdown figure tells a story that the return alone hides. The drawdown is measured within the chosen period: the running maximum starts counting on the first day of the period. That is what whoever looks at the dashboard expects ("how much have I lost at worst this year"), but it is worth stating on the page itself, because the drawdown from the all-time high can be much larger.
A weekend is a valid period for the selector and an empty one for the data. The function rejects it:
metricas(tramo(cierres, "SINT", dt.date(2025, 12, 27),
dt.date(2025, 12, 28)))ValueError: At least two sessions are neededBefore writing a single line of interface, a few minimal tests pin down what is expected from the metrics. A series that rises 10 % and then falls back to its starting level has zero return and a drawdown known in advance:
def test_metricas_ida_y_vuelta():
fechas = pd.bdate_range("2025-01-06", periods=3)
m = metricas(pd.Series([100.0, 110.0, 100.0], index=fechas))
assert m["rentabilidad"] == 0
assert abs(m["drawdown_max"] - (100 / 110 - 1)) < 1e-12
def test_tramo_incluye_los_extremos():
s = tramo(cierres, "SINT", dt.date(2025, 12, 1),
dt.date(2025, 12, 3))
assert len(s) == 3
test_metricas_ida_y_vuelta()
test_tramo_incluye_los_extremos()
print("tests passed")tests passedIn the repository, these two functions would go in tests/test_panel_datos.py and uv run pytest would run them.
Here they are called by hand so the cell shows the result.
The app: widgets, metrics and chart
With the computations solved and tested, app.py only has to request the data, read the widgets and draw. This is the
complete dashboard:
import datetime as dt
import plotly.graph_objects as go
import streamlit as st
from panel_datos import cargar_cierres, metricas, tramo
st.set_page_config(page_title="SINT dashboard", layout="wide")
@st.cache_data(ttl="1h")
def datos():
return cargar_cierres()
cierres = datos()
ultimo = cierres.index[-1].date()
ticker = st.sidebar.selectbox("Asset", list(cierres.columns))
periodo = st.sidebar.date_input(
"Period",
value=(dt.date(ultimo.year, 1, 1), ultimo),
min_value=cierres.index[0].date(),
max_value=ultimo,
)
if len(periodo) != 2:
st.info("Choose the end date of the period.")
st.stop()
serie = tramo(cierres, ticker, *periodo)
if len(serie) < 2:
st.warning("The period does not have enough sessions.")
st.stop()
m = metricas(serie)
st.title(f"{ticker} · {serie.index[0]:%d/%m/%Y} to "
f"{serie.index[-1]:%d/%m/%Y}")
c1, c2, c3 = st.columns(3)
c1.metric("Return", f"{m['rentabilidad']:.1%}")
c2.metric("Annual volatility", f"{m['volatilidad']:.1%}")
c3.metric("Maximum drawdown (in the period)",
f"{m['drawdown_max']:.1%}")
fig = go.Figure(go.Scatter(x=serie.index, y=serie, name=ticker))
fig.update_layout(template="plotly_dark", height=420,
yaxis_title="Close")
st.plotly_chart(fig)
st.caption(f"Data up to the close of {ultimo:%d/%m/%Y}.")Read from top to bottom, as Streamlit runs it:
st.set_page_configsets the title of the browser tab and the page width ("wide"uses the whole screen).datos()is the loading, wrapped in a function with@st.cache_data(next section). On the first run it reads the CSV files; on the following ones, it returns a copy of what it already read.st.sidebar.selectboxputs a dropdown with the five assets in the sidebar and returns the chosen one, astr. By default, the first in the list.st.sidebar.date_inputwith a tuple asvaluebecomes a range selector. It returns a tuple of 0 to 2 dates: while the user has marked the start but not yet the end, the tuple has only one. Unpackingdesde, hasta = …without checking breaks the app halfway through the selection; that is whylen(periodo)is checked andst.stop()halts that run with a notice. The next interaction relaunches the script.st.columns(3)spreads the three metrics across a row;metricshows a label and a large figure.st.plotly_chartdraws the Plotly figure from lesson 0.6.1, with its zoom and cursor. For something quick,st.line_chart(serie)draws the line without building the figure.st.captionsays up to which date the data goes. A dashboard that does not say it invites decisions with prices from a week ago without knowing it.
The cache: load once, not on every click
@st.cache_data stores the result of a function by its arguments. The first call with given arguments runs the
function and stores what it returns; the following ones with the same arguments skip the execution. Since the script
runs on every click, it is what separates a dashboard that responds instantly from one that rereads the data every
time.
The standard library has a similar mechanism, functools.cache, which can be run here and shows the idea:
from functools import cache
lecturas = 0
@cache
def cargar(ruta: str) -> pd.DataFrame:
global lecturas
lecturas += 1
return pd.read_csv(ruta, parse_dates=["fecha"],
index_col="fecha")
for _ in range(5): # five "clicks"
df = cargar("sint_diario.csv")
print(f"Calls: 5 · file reads: {lecturas}")
print(f"Same object? {cargar('sint_diario.csv') is df}")Calls: 5 · file reads: 1
Same object? TrueFive calls, a single read. The second line shows the important difference from Streamlit: functools.cache returns
the same object every time. If a piece of code did df["cierre"] *= 1.1 for some computation, the cache would be
modified for all the following calls. st.cache_data avoids that: it stores the result serialized (with pickle) and
hands a copy to each call. Whatever the function returns has to be serializable: tables, arrays, numbers, lists.
What is not data but a shared resource (a connection to a database, a client for the broker's API, a model loaded in
memory) goes with st.cache_resource, which does not copy: all sessions use the same object.
Three parameters that are used often:
| Parameter | Example | What for |
|---|---|---|
ttl | ttl="1h" or ttl=3600 | Expiry. Without it, cached data is never refreshed while the server stays alive |
max_entries | max_entries=20 | Limit of stored entries (one per combination of arguments) |
show_spinner | show_spinner="Loading prices…" | Text while the function runs for the first time |
The ttl is the trading decision of this section. With daily data, one hour is enough; with a dashboard that reads
intraday prices, a one-hour cache shows prices from an hour ago that look current. The cache is cleared by hand with
datos.clear() or with st.cache_data.clear().
Run it locally and store the secrets
Streamlit is installed like any project dependency (lesson 0.2.2) and started with streamlit run. The project
structure looks like this:
uv add streamlit
uv run streamlit run app.py --server.address localhost| File | Contents | In Git? |
|---|---|---|
app.py | The interface | Yes |
panel_datos.py | Loading and computations | Yes |
tests/test_panel_datos.py | Tests of the computations | Yes |
.streamlit/config.toml | Server and theme options | Yes |
.streamlit/secrets.toml | API keys, passwords | No (in .gitignore) |
streamlit run starts a web server and opens the browser at http://localhost:8501 (8501 is the default port). If no
address is set, Streamlit also shows a "Network URL": the dashboard is open to the other machines on your network,
including those on a shared Wi-Fi; --server.address localhost (or address = "localhost" in the [server] section
of .streamlit/config.toml) restricts it to your machine. While you develop, every time you save app.py Streamlit
offers to rerun it; "Always rerun" does it automatically.
As soon as the dashboard reads data from a provider or a broker, it needs a key. Streamlit reads it from
.streamlit/secrets.toml (in the folder the app is launched from; there is also a global one in
~/.streamlit/secrets.toml):
[proveedor]
api_key = "put-your-key-here"clave = st.secrets["proveedor"]["api_key"]st.secrets is read like a dictionary. The file goes in .gitignore from the first commit: a key that reaches GitHub
stays in the history even if it is deleted later, and it has to be revoked (lesson 0.2.5). The environment variables
and .env files from lesson 0.2.4 are still valid; secrets.toml is Streamlit's own way, and the one its deployment
services use.
Exercises
- Add to
panel_datos.pya functionrentabilidad_por_anio(cierre)that returns the return of each calendar year (from the last close of the previous year to the last close of the year). Test it with TECH. What does it return for the first year, which has no previous year?
Solution
def rentabilidad_por_anio(cierre: pd.Series) -> pd.Series:
"""Return of each calendar year, from close to close."""
fin_de_anio = cierre.groupby(cierre.index.year).last()
return fin_de_anio.pct_change().dropna()
rentabilidad_por_anio(cierres["TECH"]).round(3)fecha
2022 0.372
2023 0.131
2024 0.064
2025 -0.104
Name: TECH, dtype: float64For 2025 it gives −10.4 %, not the dashboard's −9.25 %: here the base is the last close of 2024; in the dashboard, the
first close of the chosen period. Both figures are correct, and that is why the dashboard says from which date to which
date it measures. The first year disappears: there is no close from the previous year to compare it with, and
pct_change() leaves a NaN that dropna() removes. Using the first close of 2021 as the base would be possible, but
it would give the return of an incomplete year with the same label as the others. If the dashboard shows it, it should
say so.
- A colleague proposes removing
@st.cache_datafromdatos()"because the CSV is small". What happens every time the user moves the date selector? And if tomorrowcargar_cierres()downloads the prices from a pay-per-call API?
Solution
Without a cache, every move of the selector reruns the script and, with it, the reading of the two CSV files. With a
small file it is barely noticeable. With an API, every click is a call: the dashboard becomes slow, it can exceed the
provider's request limit and, if you pay per call, it costs money. The cache with a reasonable ttl (one hour for daily
data) makes one call per hour regardless of the number of clicks.
- Write a function
alinear(cierres, base)that rescales all columns so they are worthbaseon the first day, and use it with the five assets in 2025. What is it for in the dashboard?
Solution
def alinear(cierres: pd.DataFrame, base: float = 100) -> pd.DataFrame:
"""Rescales each column so it starts at `base`."""
return cierres / cierres.iloc[0] * base
anio = cierres.loc[pd.Timestamp("2025-01-01"):]
alinear(anio).iloc[-1].sort_values().round(1)TECH 90.8
RFIJ 102.0
RVAR 102.6
SINT 104.4
ORO 109.4
Name: 2025-12-31 00:00:00, dtype: float64With prices at different levels (RFIJ near 100, ORO above 170), a chart with all five cannot be compared. On a base of
100, each line says how much it has gained or lost since the same day, and an st.multiselect to choose which assets
are drawn completes the dashboard.
Each answer is folded under its question
Self-assessment
In a Streamlit app, the user changes the asset in a
selectbox. What runs?- AOnly the
selectboxline - BOnly the functions that use the asset
- CThe whole script, from top to bottom, with the new widget value
- DNothing until the user reloads the page
Show answer
Answer C. The whole script, from top to bottom, with the new widget value
Every interaction with a widget reruns the whole script. That is why the expensive parts (reading data, downloading, training) are cached: without a cache, every click would repeat the loading.
- AOnly the
What is the difference between
st.cache_dataandst.cache_resource?- ANone, they are aliases
- B
st.cache_datastores serializable data and returns a copy on each call;st.cache_resourcestores a shared object, such as a connection, without copying it - C
st.cache_datastores on disk andst.cache_resourcein memory - D
st.cache_resourceonly works with DataFrames
Show answer
Answer B.
st.cache_datastores serializable data and returns a copy on each call;st.cache_resourcestores a shared object, such as a connection, without copying itA price
DataFrameis data: it is stored serialized and each call receives its own copy, so modifying it does not corrupt the cache. A connection to a database or a broker is a resource: it is shared across sessions and not copied.The dashboard has to read a data provider's API key. Where does it go?
- AIn a constant at the top of
app.py - BIn
.streamlit/secrets.toml, which is in.gitignore, and it is read withst.secrets - CIn the repository's
README.md, so the team has it - DIn a code comment
Show answer
Answer B. In
.streamlit/secrets.toml, which is in.gitignore, and it is read withst.secretsThe secrets file stays outside version control and the app reads it with
st.secrets. A key written in the code ends up in the Git history even if it is deleted later (lesson 0.2.5).- AIn a constant at the top of
Why are the dashboard's computation functions in
panel_datos.pyand not insideapp.py?- ABecause Streamlit does not allow functions in
app.py - BSo the dashboard loads faster
- CSo they can be tested with pytest or in a notebook without starting the interface, and reused in other scripts
- DBecause
st.cache_dataonly works with imported functions
Show answer
Answer C. So they can be tested with pytest or in a notebook without starting the interface, and reused in other scripts
The logic that computes returns or drawdowns is the one that can have costly errors. Outside the interface it is tested like any function;
app.pyremains a thin layer that only draws.- ABecause Streamlit does not allow functions in
Key points
- Streamlit runs the whole script, from top to bottom, every time the user touches a widget. Everything else follows from that rule.
- The data functions go in their own module, without
import streamlit, and are tested like any other function. st.cache_dataavoids repeating the expensive parts: it stores the result by arguments and returns a copy;ttldecides how long it lives.- Widgets return Python types (
datetime.date, tuples of 0 to 2 dates): convert them and validate the incomplete case before using them. - Locally,
--server.address localhostkeeps the dashboard to your machine only; keys go in.streamlit/secrets.toml, outside Git.