---
title: "0.6.2 Quick dashboards with Streamlit"
description: "A price dashboard with Streamlit: execution model, data functions tested separately, widgets, st.cache_data, local execution and secrets."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/streamlit-dashboards/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/streamlit-dashboards.png"
---

# 0.6.2 · 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.

- Course: [Level 0: Python from scratch and a data mindset](https://pythonparatrading.com/en/python-trading-course/level-0/)
- Module: 0.6 · Visualisation
- Duration: 45 min
- Prerequisites: [0.6.1 · Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts](https://pythonparatrading.com/en/python-trading-course/level-0/matplotlib-seaborn-plotly-charts/)

## By the end of this lesson

- Explain Streamlit's execution model (the whole script on every interaction) and predict what is recomputed when a widget moves.
- Separate the data functions from the interface and test them without Streamlit, with `datetime.date` dates like those the widgets return.
- Write an `app.py` with an asset selector, a date range, metrics and a chart, and cache the loading with `st.cache_data`.
- Run the dashboard locally for your machine only and read keys from `.streamlit/secrets.toml` without pushing them to the repository.

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

```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)
```
```output
(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:

```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}")
```
```output
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 $\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`:

```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%}")
```
```output
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:

```python raises
metricas(tramo(cierres, "SINT", dt.date(2025, 12, 27),
               dt.date(2025, 12, 28)))
```
```output
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:

```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")
```
```output
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:

```python skip title="app.py"
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.

<aside class="callout" data-kind="riesgo">

A monitoring dashboard shows what has already happened. If it also computes a signal ("buy tomorrow"), it must use
only complete closes: a dashboard opened mid-session that treats the last price as the day's close is trading with
information the backtest did not have (the look-ahead bias of lesson 0.1.1, in its live version).

</aside>

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

```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}")
```
```output
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:

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

```bash
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`):

```toml title=".streamlit/secrets.toml"
[proveedor]
api_key = "put-your-key-here"
```

```python skip
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.

<aside class="callout" data-kind="aviso">

Publishing the dashboard on the Internet is another problem: authentication, HTTPS and who can see your positions. For
a personal dashboard, localhost is enough. A dashboard with data from a real account is not exposed without access
control.

</aside>

## 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.**

```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)
```
```output
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.

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

3. 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.**

```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)
```
```output
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.

## Self-assessment

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

   - A. Only the `selectbox` line
   - B. Only the functions that use the asset
   - C. The whole script, from top to bottom, with the new widget value
   - D. Nothing until the user reloads the page

   *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`?

   - A. None, they are aliases
   - 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
   - C. `st.cache_data` stores on disk and `st.cache_resource` in memory
   - D. `st.cache_resource` only works with DataFrames

   *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?

   - A. In a constant at the top of `app.py`
   - B. In `.streamlit/secrets.toml`, which is in `.gitignore`, and it is read with `st.secrets`
   - C. In the repository's `README.md`, so the team has it
   - D. In a code comment

   *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`?

   - A. Because Streamlit does not allow functions in `app.py`
   - B. So the dashboard loads faster
   - C. So they can be tested with pytest or in a notebook without starting the interface, and reused in other scripts
   - D. Because `st.cache_data` only works with imported functions

   *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

### Research

- [Advanced Financial Report Library in Python](https://pythonparatrading.com/en/advanced-reporting-2/) · Research-0016 · 3 min

### Documentation

- [Streamlit: basic concepts and data flow](https://docs.streamlit.io/get-started/fundamentals/main-concepts) · docs.streamlit.io
- [Streamlit: caching overview](https://docs.streamlit.io/develop/concepts/architecture/caching) · docs.streamlit.io
- [Streamlit: st.date_input](https://docs.streamlit.io/develop/api-reference/widgets/st.date_input) · docs.streamlit.io
- [Streamlit: secrets management](https://docs.streamlit.io/develop/concepts/connections/secrets-management) · docs.streamlit.io

### Data

- [Level 0 data: closes of the fictitious portfolio (CSV)](https://pythonparatrading.com/curso/datos/nivel-0/cartera_sint.csv) · Download · cartera_sint.csv

Previous lesson: [0.6.1 · Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts](https://pythonparatrading.com/en/python-trading-course/level-0/matplotlib-seaborn-plotly-charts/)

Next lesson: [0.7.1 · Simple and log returns, compound interest and descriptive statistics](https://pythonparatrading.com/en/python-trading-course/level-0/returns-and-descriptive-statistics/)
