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.

02/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

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.

ToolWhat it is forWho uses it
Notebook (Jupyter, Marimo)Research, testing ideas, documenting an analysisWhoever writes the code
Script with savefig()Fixed reports that generate themselves (PDF, PNG)Anyone, without interaction
Streamlit dashboardDaily monitoring with filters: asset, period, strategyAnyone, with interaction
Custom web applicationProduct with users, permissions and heavy loadA 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.

already computednewTouches a widgetWhole scriptReads the widgetsCached functionCopy from cacheRuns and storesDraws the page
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 --> U

Three 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 + 1 starts over on every click, because the script starts again. Whatever must be remembered between clicks (a list of orders the user keeps adding) goes in st.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:

IN [01]python
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)
OUT [01]panel_datos.py · loading
(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.22

how="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:

IN [02]python
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}")
OUT [02]panel_datos.py · period
TECH: 261 sessions, from 2025-01-01 to 2025-12-31

The 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 252\sqrt{252}; 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:

IN [03]python
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%}")
OUT [03]panel_datos.py · metrics
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:

IN [04]python
metricas(tramo(cierres, "SINT", dt.date(2025, 12, 27),
               dt.date(2025, 12, 28)))
OUT [04]Period without sessions
ValueError: At least two sessions are needed

Before 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:

IN [05]python
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")
OUT [05]Tests of the metrics
tests passed

In 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:

IN [06]app.pypython
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_config sets 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.selectbox puts a dropdown with the five assets in the sidebar and returns the chosen one, a str. By default, the first in the list.
  • st.sidebar.date_input with a tuple as value becomes 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. Unpacking desde, hasta = … without checking breaks the app halfway through the selection; that is why len(periodo) is checked and st.stop() halts that run with a notice. The next interaction relaunches the script.
  • st.columns(3) spreads the three metrics across a row; metric shows a label and a large figure.
  • st.plotly_chart draws 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.caption says 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:

IN [07]python
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}")
OUT [07]Cache by arguments
Calls: 5 · file reads: 1
Same object? True

Five 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:

ParameterExampleWhat for
ttlttl="1h" or ttl=3600Expiry. Without it, cached data is never refreshed while the server stays alive
max_entriesmax_entries=20Limit of stored entries (one per combination of arguments)
show_spinnershow_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
FileContentsIn Git?
app.pyThe interfaceYes
panel_datos.pyLoading and computationsYes
tests/test_panel_datos.pyTests of the computationsYes
.streamlit/config.tomlServer and theme optionsYes
.streamlit/secrets.tomlAPI keys, passwordsNo (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):

IN [08].streamlit/secrets.tomltoml
[proveedor]
api_key = "put-your-key-here"
IN [09]python
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

  1. Add to panel_datos.py a function rentabilidad_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
IN [10]python
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)
OUT [10]Return by year
fecha
2022    0.372
2023    0.131
2024    0.064
2025   -0.104
Name: TECH, dtype: float64

For 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.

  1. A colleague proposes removing @st.cache_data from datos() "because the CSV is small". What happens every time the user moves the date selector? And if tomorrow cargar_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.

  1. Write a function alinear(cierres, base) that rescales all columns so they are worth base on the first day, and use it with the five assets in 2025. What is it for in the dashboard?
Solution
IN [11]python
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)
OUT [11]Comparing assets on a base of 100
TECH     90.8
RFIJ    102.0
RVAR    102.6
SINT    104.4
ORO     109.4
Name: 2025-12-31 00:00:00, dtype: float64

With 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

  1. In a Streamlit app, the user changes the asset in a selectbox. What runs?

    • AOnly the selectbox line
    • 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.

  2. What is the difference between st.cache_data and st.cache_resource?

    • ANone, they are aliases
    • Bst.cache_data stores serializable data and returns a copy on each call; st.cache_resource stores a shared object, such as a connection, without copying it
    • Cst.cache_data stores on disk and st.cache_resource in memory
    • Dst.cache_resource only works with DataFrames
    Show answer

    Answer B. st.cache_data stores serializable data and returns a copy on each call; st.cache_resource stores a shared object, such as a connection, without copying it

    A price DataFrame is 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.

  3. 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 with st.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 with st.secrets

    The 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).

  4. Why are the dashboard's computation functions in panel_datos.py and not inside app.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_data only 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.py remains a thin layer that only draws.

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_data avoids repeating the expensive parts: it stores the result by arguments and returns a copy; ttl decides 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 localhost keeps the dashboard to your machine only; keys go in .streamlit/secrets.toml, outside Git.

Resources

Documentation