---
title: "0.6.2 Dashboards rápidos con Streamlit"
description: "Un panel de precios con Streamlit: modelo de ejecución, funciones de datos probadas aparte, widgets, st.cache_data, ejecución local y secretos."
url: "https://pythonparatrading.com/curso-python-trading/nivel-0/dashboards-con-streamlit/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-0/dashboards-con-streamlit.png"
---

# 0.6.2 · Dashboards rápidos con Streamlit: un panel de seguimiento en un solo script

> Un notebook sirve para investigar; un panel, para mirar cada día lo mismo sin tocar código. Streamlit convierte un script de Python en una página web con selectores y gráficos. Esta lección monta un panel de precios y drawdown sobre los datos del curso y explica la única idea que hay que entender para no pelearse con él: el script se ejecuta entero en cada clic.

- Curso: [Nivel 0: Python desde cero y mentalidad de datos](https://pythonparatrading.com/curso-python-trading/nivel-0/)
- Módulo: 0.6 · Visualización
- Duración: 45 min
- Requisitos: [0.6.1 · Matplotlib orientado a objetos, Seaborn y Plotly: gráficos de precios, drawdown y velas](https://pythonparatrading.com/curso-python-trading/nivel-0/graficos-matplotlib-seaborn-y-plotly/)

## Al terminar esta lección

- Explicar el modelo de ejecución de Streamlit (el script entero en cada interacción) y prever qué se recalcula al mover un widget.
- Separar las funciones de datos de la interfaz y probarlas sin Streamlit, con fechas de tipo `datetime.date` como las que devuelven los widgets.
- Escribir un `app.py` con selector de activo, rango de fechas, métricas y gráfico, y cachear la carga con `st.cache_data`.
- Ejecutar el panel en local solo para tu equipo y leer claves desde `.streamlit/secrets.toml` sin subirlas al repositorio.

## Del notebook al panel

En la lección 0.6.1 los gráficos se hacían en un notebook: se cambia una línea, se ejecuta la celda y se mira el
resultado. Para investigar es lo correcto. Para seguir una cartera cada mañana no lo es: nadie quiere editar código
para cambiar de activo o de periodo, y menos aún pedírselo a un compañero que no programa.

Un **panel** (*dashboard*) resuelve eso: una página con unos pocos controles (activo, fechas) y siempre las mismas
cifras y gráficos. **Streamlit** construye esa página a partir de un script de Python normal, sin HTML ni JavaScript.
Cada `st.algo(…)` del script añade un elemento a la página, en el orden en que aparece.

| Herramienta | Para qué sirve | Quién lo usa |
|---|---|---|
| Notebook (Jupyter, Marimo) | Investigar, probar ideas, documentar un análisis | Quien escribe el código |
| Script con `savefig()` | Informes fijos que se generan solos (PDF, PNG) | Cualquiera, sin interacción |
| Panel de Streamlit | Seguimiento diario con filtros: activo, periodo, estrategia | Cualquiera, con interacción |
| Aplicación web a medida | Producto con usuarios, permisos y mucha carga | Un equipo de desarrollo |

Streamlit ocupa el hueco entre el notebook y la aplicación a medida: en un centenar de líneas hay un panel útil. A
cambio, su forma de ejecutarse es distinta de la de un script normal, y casi todos los problemas de un panel lento o
que «se olvida» de los datos vienen de no entenderla.

Streamlit no forma parte del entorno del curso: el código de la interfaz se muestra pero no se ejecuta en esta página.
Lo que sí se ejecuta, y es lo importante, son las funciones de datos que el panel usa.

## El script entero, en cada clic

Un script normal se ejecuta una vez, de la primera línea a la última, y termina. Una app de Streamlit se ejecuta
**entera, de arriba abajo, cada vez que el usuario toca un widget** (y cada vez que guardas el archivo mientras la
desarrollas). Cuando el usuario cambia el activo en un selector, Streamlit no ejecuta «la parte del selector»: vuelve a
lanzar todo el script, y esta vez la línea del selector devuelve el activo nuevo.

```mermaid
flowchart TB
  U[Toca un widget] --> S[Script completo]
  S --> W[Lee los widgets]
  W --> C{Función cacheada}
  C -->|ya calculada| K[Copia de caché]
  C -->|nueva| F[Ejecuta y guarda]
  K --> P[Pinta la página]
  F --> P
  P --> U
```

De esa regla salen tres consecuencias prácticas:

- **Lo caro se repite si no se cachea.** Leer un CSV de cinco años tarda poco; descargar precios de un proveedor o
  leer un Parquet de varios gigas, no. Sin caché, cada clic lo repite.
- **Las variables no sobreviven entre ejecuciones.** Un contador `n = n + 1` vuelve a empezar en cada clic, porque el
  script empieza de nuevo. Lo que deba recordarse entre clics (una lista de órdenes que el usuario va añadiendo) va en
  `st.session_state`, que este panel no necesita.
- **El orden del script es el orden de la página.** No hay callbacks que ordenar ni eventos que conectar: se lee de
  arriba abajo, como el notebook del que viene.

## Las funciones de datos, sin Streamlit

La regla de diseño más útil para un panel: **la interfaz no calcula nada**. Las funciones que leen datos, recortan
periodos y calculan métricas van en un módulo propio, `panel_datos.py`, que no importa Streamlit. Así se prueban
como cualquier función (lección 0.4.5), se reutilizan en un notebook o en un informe, y `app.py` queda como una capa
fina que solo pinta.

El panel trabaja con cinco activos ficticios del curso: SINT y los cuatro de `cartera_sint.csv`. La primera función
junta sus cierres en una tabla con una columna por activo:

```python
import numpy as np
import pandas as pd


def cargar_cierres() -> pd.DataFrame:
    """Cierres diarios: una columna por activo, índice de fechas."""
    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"` solo conserva las fechas que están en los dos archivos. Aquí coinciden todas, pero con datos reales de
dos mercados con festivos distintos (una bolsa europea y una americana) no pasaría, y es mejor que la función lo
resuelva de forma explícita que encontrarse huecos en el gráfico.

La segunda función recorta un activo a un periodo. El detalle está en el tipo de las fechas: el selector de fechas de
Streamlit devuelve objetos `datetime.date`, mientras que el índice de pandas es de `Timestamp`. Recortar un índice de
fechas con `datetime.date` está obsoleto en pandas 3 (emite un aviso y dejará de funcionar), así que la función
convierte antes de recortar:

```python
import datetime as dt


def tramo(cierres: pd.DataFrame, ticker: str,
          desde: dt.date, hasta: dt.date) -> pd.Series:
    """Cierres de un activo entre dos fechas, ambas incluidas."""
    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)} sesiones, "
      f"del {serie.index[0]:%Y-%m-%d} al {serie.index[-1]:%Y-%m-%d}")
```
```output
TECH: 261 sesiones, del 2025-01-01 al 2025-12-31
```

Las 261 sesiones incluyen el 1 de enero: SINT y la cartera del curso no tienen festivos. Con datos de un mercado
real, el primer día del tramo sería la primera sesión del año, y la función no necesita saberlo.

La tercera calcula las cifras que el panel enseña arriba: rentabilidad del periodo, volatilidad anualizada (la
desviación típica diaria por $\sqrt{252}$; el porqué está en la lección 0.7.1) y drawdown máximo. Un periodo con menos
de dos sesiones no tiene ninguna de las tres, y la función lo dice con un error claro en lugar de devolver `NaN`:

```python
def metricas(cierre: pd.Series) -> dict[str, float]:
    """Rentabilidad, volatilidad anual y drawdown máximo."""
    if len(cierre) < 2:
        raise ValueError("Hacen falta al menos dos sesiones")
    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 cierra 2025 con una pérdida del 9,25 %, pero por el camino llegó a perder un 31,91 %: la cifra de drawdown
cuenta una historia que la rentabilidad sola esconde. El drawdown se mide **dentro del periodo elegido**: el máximo acumulado empieza a contar el primer día del tramo. Es
lo que espera quien mira el panel («cuánto he llegado a perder este año»), pero conviene decirlo en la propia página,
porque el drawdown desde el máximo histórico puede ser mucho mayor.

Un fin de semana es un periodo válido para el selector y vacío para los datos. La función lo rechaza:

```python raises
metricas(tramo(cierres, "SINT", dt.date(2025, 12, 27),
               dt.date(2025, 12, 28)))
```
```output
ValueError: Hacen falta al menos dos sesiones
```

Antes de escribir una línea de interfaz, unas pruebas mínimas fijan lo que se espera de las métricas. Una serie que
sube un 10 % y luego cae a su nivel inicial tiene rentabilidad cero y un drawdown conocido de antemano:

```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("pruebas superadas")
```
```output
pruebas superadas
```

En el repositorio, estas dos funciones irían en `tests/test_panel_datos.py` y las ejecutaría `uv run pytest`. Aquí se
llaman a mano para que la celda muestre el resultado.

## La app: widgets, métricas y gráfico

Con los cálculos resueltos y probados, `app.py` solo tiene que pedir los datos, leer los widgets y pintar. Este es el
panel completo:

```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="Panel SINT", layout="wide")


@st.cache_data(ttl="1h")
def datos():
    return cargar_cierres()


cierres = datos()
ultimo = cierres.index[-1].date()

ticker = st.sidebar.selectbox("Activo", list(cierres.columns))
periodo = st.sidebar.date_input(
    "Periodo",
    value=(dt.date(ultimo.year, 1, 1), ultimo),
    min_value=cierres.index[0].date(),
    max_value=ultimo,
)
if len(periodo) != 2:
    st.info("Elige la fecha final del periodo.")
    st.stop()

serie = tramo(cierres, ticker, *periodo)
if len(serie) < 2:
    st.warning("El periodo no tiene sesiones suficientes.")
    st.stop()

m = metricas(serie)
st.title(f"{ticker} · {serie.index[0]:%d/%m/%Y} a "
         f"{serie.index[-1]:%d/%m/%Y}")
c1, c2, c3 = st.columns(3)
c1.metric("Rentabilidad", f"{m['rentabilidad']:.1%}")
c2.metric("Volatilidad anual", f"{m['volatilidad']:.1%}")
c3.metric("Drawdown máximo (en el periodo)",
          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="Cierre")
st.plotly_chart(fig)
st.caption(f"Datos hasta el cierre del {ultimo:%d/%m/%Y}.")
```

Leído de arriba abajo, como lo ejecuta Streamlit:

- **`st.set_page_config`** fija el título de la pestaña del navegador y el ancho de la página (`"wide"` aprovecha
  toda la pantalla).
- **`datos()`** es la carga, envuelta en una función con `@st.cache_data` (siguiente sección). En la primera ejecución
  lee los CSV; en las siguientes, devuelve una copia de lo que ya leyó.
- **`st.sidebar.selectbox`** pone en la barra lateral un desplegable con los cinco activos y devuelve el elegido, un
  `str`. Por defecto, el primero de la lista.
- **`st.sidebar.date_input`** con una tupla como `value` se convierte en un selector de rango. Devuelve una tupla de
  **0 a 2 fechas**: mientras el usuario ha marcado el inicio y aún no el final, la tupla tiene una sola. Desempaquetar
  `desde, hasta = …` sin comprobarlo rompe la app a mitad de selección; por eso se mira `len(periodo)` y
  **`st.stop()`** detiene esa ejecución con un aviso. La siguiente interacción vuelve a lanzar el script.
- **`st.columns(3)`** reparte las tres métricas en una fila; **`metric`** muestra una etiqueta y una cifra grande.
- **`st.plotly_chart`** pinta la figura de Plotly de la lección 0.6.1, con su zoom y su cursor. Para algo rápido,
  `st.line_chart(serie)` dibuja la línea sin construir la figura.
- **`st.caption`** dice hasta qué fecha llegan los datos. Un panel que no lo dice invita a tomar decisiones con
  precios de hace una semana sin saberlo.

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

Un panel de seguimiento enseña lo que ya ha pasado. Si además calcula una señal («comprar mañana»), tiene que usar
solo cierres completos: un panel abierto a media sesión que trate el último precio como cierre del día está operando
con información que el backtest no tenía (el sesgo de anticipación de la lección 0.1.1, en versión en vivo).

</aside>

## La caché: cargar una vez, no en cada clic

`@st.cache_data` guarda el resultado de una función según sus argumentos. La primera llamada con unos argumentos dados
ejecuta la función y guarda lo que devuelve; las siguientes con los mismos argumentos se saltan la ejecución. Como el
script se ejecuta en cada clic, es lo que separa un panel que responde al instante de uno que relee los datos cada
vez.

La biblioteca estándar tiene un mecanismo parecido, `functools.cache`, que sí se puede ejecutar aquí y enseña la 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):        # cinco «clics»
    df = cargar("sint_diario.csv")
print(f"Llamadas: 5 · lecturas del archivo: {lecturas}")
print(f"¿Mismo objeto? {cargar('sint_diario.csv') is df}")
```
```output
Llamadas: 5 · lecturas del archivo: 1
¿Mismo objeto? True
```

Cinco llamadas, una sola lectura. La segunda línea muestra la diferencia importante con Streamlit: `functools.cache`
devuelve **el mismo objeto** cada vez. Si un trozo del código hiciera `df["cierre"] *= 1.1` para un cálculo
cualquiera, la caché quedaría modificada para todas las llamadas siguientes. `st.cache_data` lo evita: guarda el
resultado serializado (con `pickle`) y entrega **una copia** a cada llamada. Lo que la función devuelva tiene que poder
serializarse: tablas, arrays, números, listas.

Lo que no es un dato sino un recurso compartido —una conexión a una base de datos, un cliente de la API del bróker, un
modelo cargado en memoria— va con **`st.cache_resource`**, que no copia: todas las sesiones usan el mismo objeto.

Tres parámetros que se usan a menudo:

| Parámetro | Ejemplo | Para qué |
|---|---|---|
| `ttl` | `ttl="1h"` o `ttl=3600` | Caducidad. Sin él, los datos cacheados no se renuevan nunca mientras el servidor siga vivo |
| `max_entries` | `max_entries=20` | Límite de entradas guardadas (una por combinación de argumentos) |
| `show_spinner` | `show_spinner="Cargando precios…"` | Texto mientras la función se ejecuta la primera vez |

El `ttl` es la decisión de trading de esta sección. Con datos diarios, una hora basta; con un panel que lee precios
intradía, una caché de una hora enseña precios de hace una hora con aspecto de actuales. La caché se borra a mano con
`datos.clear()` o con `st.cache_data.clear()`.

## Ejecutarla en local y guardar los secretos

Streamlit se instala como cualquier dependencia del proyecto (lección 0.2.2) y se arranca con `streamlit run`. La
estructura del proyecto queda así:

```bash
uv add streamlit
uv run streamlit run app.py --server.address localhost
```

| Archivo | Contenido | ¿En Git? |
|---|---|---|
| `app.py` | La interfaz | Sí |
| `panel_datos.py` | Carga y cálculos | Sí |
| `tests/test_panel_datos.py` | Pruebas de los cálculos | Sí |
| `.streamlit/config.toml` | Opciones del servidor y del tema | Sí |
| `.streamlit/secrets.toml` | Claves de API, contraseñas | **No** (en `.gitignore`) |

`streamlit run` arranca un servidor web y abre el navegador en `http://localhost:8501` (8501 es el puerto por
defecto). Si no se fija una dirección, Streamlit muestra además una «Network URL»: el panel queda abierto a los
demás equipos de tu red, también a los de una wifi compartida; `--server.address localhost` (o `address = "localhost"` en la sección `[server]` de
`.streamlit/config.toml`) lo limita a tu equipo. Mientras desarrollas, cada vez que guardas `app.py` Streamlit
ofrece volver a ejecutarlo; «Always rerun» lo hace solo.

En cuanto el panel lee datos de un proveedor o de un bróker, necesita una clave. Streamlit la lee de
`.streamlit/secrets.toml` (en la carpeta desde la que se lanza la app; también existe uno global en
`~/.streamlit/secrets.toml`):

```toml title=".streamlit/secrets.toml"
[proveedor]
api_key = "pon-aqui-tu-clave"
```

```python skip
clave = st.secrets["proveedor"]["api_key"]
```

`st.secrets` se lee como un diccionario. El archivo va en `.gitignore` desde el primer commit: una clave que llega a
GitHub sigue en el historial aunque se borre después, y hay que revocarla (lección 0.2.5). Las variables de entorno y
los archivos `.env` de la lección 0.2.4 siguen siendo válidos; `secrets.toml` es la forma propia de Streamlit, y la que
usan sus servicios de despliegue.

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

Publicar el panel en Internet es otro problema: autenticación, HTTPS y quién puede ver tus posiciones. Para un panel
personal, localhost basta. Un panel con datos de una cuenta real no se expone sin control de acceso.

</aside>

## Ejercicios

1. Añade a `panel_datos.py` una función `rentabilidad_por_anio(cierre)` que devuelva la rentabilidad de cada año
   natural (del último cierre del año anterior al último del año). Pruébala con TECH. ¿Qué devuelve para el primer
   año, que no tiene año anterior?

**Solución.**

```python
def rentabilidad_por_anio(cierre: pd.Series) -> pd.Series:
    """Rentabilidad de cada año natural, de cierre a cierre."""
    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
```

Para 2025 sale un −10,4 %, no el −9,25 % del panel: aquí la base es el último cierre de 2024; en el panel, el
primer cierre del periodo elegido. Las dos cifras son correctas, y por eso el panel dice de qué fecha a qué fecha
mide. El primer año desaparece: no hay cierre del año anterior con el que compararlo, y `pct_change()` deja un `NaN` que
`dropna()` quita. Usar el primer cierre de 2021 como base sería posible, pero daría una rentabilidad de un año
incompleto con la misma etiqueta que las demás. Si el panel la enseña, que lo diga.

2. Un compañero propone quitar `@st.cache_data` de `datos()` «porque el CSV es pequeño». ¿Qué pasa cada vez que el
   usuario mueve el selector de fechas? ¿Y si mañana `cargar_cierres()` descarga los precios de una API de pago por
   llamada?

**Solución.**

Sin caché, cada movimiento del selector vuelve a ejecutar el script y, con él, la lectura de los dos CSV. Con un
archivo pequeño apenas se nota. Con una API, cada clic es una llamada: el panel se vuelve lento, puede superar el
límite de peticiones del proveedor y, si se paga por llamada, cuesta dinero. La caché con un `ttl` razonable (una hora
para datos diarios) hace una llamada por hora sea cual sea el número de clics.

3. Escribe una función `alinear(cierres, base)` que reescale todas las columnas para que valgan `base` el primer día,
   y úsala con los cinco activos en 2025. ¿Para qué sirve en el panel?

**Solución.**

```python
def alinear(cierres: pd.DataFrame, base: float = 100) -> pd.DataFrame:
    """Reescala cada columna para que empiece en `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
```

Con precios de niveles distintos (RFIJ cerca de 100, ORO por encima de 170), un gráfico con los cinco no se puede
comparar. En base 100, cada línea dice cuánto ha ganado o perdido desde el mismo día, y un `st.multiselect` para
elegir qué activos se dibujan completa el panel.

## Autoevaluación

1. En una app de Streamlit, el usuario cambia el activo en un `selectbox`. ¿Qué se ejecuta?

   - A. Solo la línea del `selectbox`
   - B. Solo las funciones que usan el activo
   - C. El script completo, de arriba abajo, con el nuevo valor del widget
   - D. Nada hasta que el usuario recarga la página

   *Respuesta C: El script completo, de arriba abajo, con el nuevo valor del widget.* Cada interacción con un widget vuelve a ejecutar el script entero. Por eso lo caro (leer datos, descargar, entrenar) se cachea: sin caché, cada clic repetiría la carga.

2. ¿Qué diferencia hay entre `st.cache_data` y `st.cache_resource`?

   - A. Ninguna, son alias
   - B. `st.cache_data` guarda datos serializables y devuelve una copia a cada llamada; `st.cache_resource` guarda un objeto compartido, como una conexión, sin copiarlo
   - C. `st.cache_data` guarda en disco y `st.cache_resource` en memoria
   - D. `st.cache_resource` solo funciona con DataFrames

   *Respuesta B: `st.cache_data` guarda datos serializables y devuelve una copia a cada llamada; `st.cache_resource` guarda un objeto compartido, como una conexión, sin copiarlo.* Un `DataFrame` de precios es un dato: se guarda serializado y cada llamada recibe su copia, así que modificarla no estropea la caché. Una conexión a una base de datos o a un bróker es un recurso: se comparte entre sesiones y no se copia.

3. El panel tiene que leer la clave API de un proveedor de datos. ¿Dónde va?

   - A. En una constante al principio de `app.py`
   - B. En `.streamlit/secrets.toml`, que está en `.gitignore`, y se lee con `st.secrets`
   - C. En el `README.md` del repositorio, para que el equipo la tenga
   - D. En un comentario del código

   *Respuesta B: En `.streamlit/secrets.toml`, que está en `.gitignore`, y se lee con `st.secrets`.* El archivo de secretos queda fuera del control de versiones y la app lo lee con `st.secrets`. Una clave escrita en el código acaba en el historial de Git aunque se borre después (lección 0.2.5).

4. ¿Por qué las funciones de cálculo del panel están en `panel_datos.py` y no dentro de `app.py`?

   - A. Porque Streamlit no admite funciones en `app.py`
   - B. Para que el panel cargue más rápido
   - C. Para poder probarlas con pytest o en un notebook sin arrancar la interfaz, y reutilizarlas en otros scripts
   - D. Porque `st.cache_data` solo funciona con funciones importadas

   *Respuesta C: Para poder probarlas con pytest o en un notebook sin arrancar la interfaz, y reutilizarlas en otros scripts.* La lógica que calcula rentabilidades o drawdowns es la que puede tener errores caros. Fuera de la interfaz se prueba como cualquier función; `app.py` queda como una capa fina que solo pinta.

## Ideas clave

- Streamlit ejecuta el script entero, de arriba abajo, cada vez que el usuario toca un widget. Todo lo demás se deduce de esa regla.
- Las funciones de datos van en un módulo propio, sin `import streamlit`, y se prueban como cualquier otra función.
- `st.cache_data` evita repetir lo caro: guarda el resultado según los argumentos y devuelve una copia; `ttl` decide cuánto vive.
- Los widgets devuelven tipos de Python (`datetime.date`, tuplas de 0 a 2 fechas): conviértelos y valida el caso incompleto antes de usarlos.
- En local, `--server.address localhost` deja el panel solo para tu equipo; las claves van en `.streamlit/secrets.toml`, fuera de Git.

## Recursos

### Research

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

### Documentación

- [Streamlit: conceptos básicos y flujo de datos](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

### Datos

- [Datos del nivel 0: cierres de la cartera ficticia (CSV)](https://pythonparatrading.com/curso/datos/nivel-0/cartera_sint.csv) · Descarga · cartera_sint.csv

Lección anterior: [0.6.1 · Matplotlib orientado a objetos, Seaborn y Plotly: gráficos de precios, drawdown y velas](https://pythonparatrading.com/curso-python-trading/nivel-0/graficos-matplotlib-seaborn-y-plotly/)

Lección siguiente: [0.7.1 · Rentabilidades simples y logarítmicas, interés compuesto y estadística descriptiva](https://pythonparatrading.com/curso-python-trading/nivel-0/rentabilidades-y-estadistica-descriptiva/)
