Lección 0.6.2 · 45 min
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.
- Módulo
- 0.6 · Visualización
- Duración
- 45 min
Lecciones del nivel 0
0.1 · Bienvenida y expectativas
0.2 · Entorno de trabajo moderno
- 0.2.1 Instalación de Python con uv: versiones y ciclo de soporte
- 0.2.2 Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles
- 0.2.3 Editores (VS Code, Cursor, PyCharm) y notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Terminal básica, rutas, variables de entorno y ficheros .env
- 0.2.5 Git y GitHub: commits, ramas, pull requests y .gitignore
- 0.2.6 Introducción a los asistentes de código con IA: prompts, revisión y tests
0.3 · Fundamentos del lenguaje
- 0.3.1 Variables, tipos y operadores; coma flotante y decimal para precios y ticks
- 0.3.2 Cadenas, f-strings y formato de números y fechas
- 0.3.3 Listas, tuplas, diccionarios, conjuntos y comprensiones
- 0.3.4 Control de flujo: if, elif, else, for, while y match-case
- 0.3.5 Funciones: argumentos, *args y **kwargs, lambdas y ámbito
- 0.3.6 Excepciones; módulos y paquetes, con los imports perezosos de Python 3.15
0.4 · Python intermedio
- 0.4.1 Programación orientada a objetos: clases, herencia, composición y dataclasses
- 0.4.2 Tipado: type hints, mypy o ty y Pydantic para configuraciones
- 0.4.3 Iteradores, generadores, decoradores y context managers
- 0.4.4 Ficheros (CSV, JSON, YAML, TOML) y fechas con zonas horarias
- 0.4.5 Logging profesional y testing con pytest
- 0.4.6 Concurrencia: threading, multiprocessing, asyncio y free-threading
0.5 · Computación numérica y datos
- 0.5.1 NumPy 2: arrays, broadcasting, vectorización, álgebra lineal y generadores aleatorios
- 0.5.2 pandas 3.0: Series, DataFrame, índices temporales, Copy-on-Write, tipo str y pd.col()
- 0.5.3 Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles
- 0.5.4 Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas
- 0.5.5 DuckDB y SQL esencial: SELECT, JOIN, GROUP BY y funciones de ventana sobre Parquet
0.6 · Visualización
0.7 · Matemáticas y estadística mínimas
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.
Fuente 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 --> UDe 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 + 1vuelve 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 enst.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:
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)(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" 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:
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}")TECH: 261 sesiones, del 2025-01-01 al 2025-12-31Las 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 ; 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:
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%}")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:
metricas(tramo(cierres, "SINT", dt.date(2025, 12, 27),
dt.date(2025, 12, 28)))ValueError: Hacen falta al menos dos sesionesAntes 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:
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")pruebas superadasEn 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:
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_configfija 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.selectboxpone en la barra lateral un desplegable con los cinco activos y devuelve el elegido, unstr. Por defecto, el primero de la lista.st.sidebar.date_inputcon una tupla comovaluese 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. Desempaquetardesde, hasta = …sin comprobarlo rompe la app a mitad de selección; por eso se miralen(periodo)yst.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;metricmuestra una etiqueta y una cifra grande.st.plotly_chartpinta 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.captiondice hasta qué fecha llegan los datos. Un panel que no lo dice invita a tomar decisiones con precios de hace una semana sin saberlo.
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:
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}")Llamadas: 5 · lecturas del archivo: 1
¿Mismo objeto? TrueCinco 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í:
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):
[proveedor]
api_key = "pon-aqui-tu-clave"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.
Ejercicios
- Añade a
panel_datos.pyuna funciónrentabilidad_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
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)fecha
2022 0.372
2023 0.131
2024 0.064
2025 -0.104
Name: TECH, dtype: float64Para 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.
- Un compañero propone quitar
@st.cache_datadedatos()«porque el CSV es pequeño». ¿Qué pasa cada vez que el usuario mueve el selector de fechas? ¿Y si mañanacargar_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.
- Escribe una función
alinear(cierres, base)que reescale todas las columnas para que valganbaseel primer día, y úsala con los cinco activos en 2025. ¿Para qué sirve en el panel?
Solución
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)TECH 90.8
RFIJ 102.0
RVAR 102.6
SINT 104.4
ORO 109.4
Name: 2025-12-31 00:00:00, dtype: float64Con 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.
Respuesta plegada en cada pregunta
Autoevaluación
En una app de Streamlit, el usuario cambia el activo en un
selectbox. ¿Qué se ejecuta?- ASolo la línea del
selectbox - BSolo las funciones que usan el activo
- CEl script completo, de arriba abajo, con el nuevo valor del widget
- DNada hasta que el usuario recarga la página
Ver respuesta
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.
- ASolo la línea del
¿Qué diferencia hay entre
st.cache_datayst.cache_resource?- ANinguna, son alias
- B
st.cache_dataguarda datos serializables y devuelve una copia a cada llamada;st.cache_resourceguarda un objeto compartido, como una conexión, sin copiarlo - C
st.cache_dataguarda en disco yst.cache_resourceen memoria - D
st.cache_resourcesolo funciona con DataFrames
Ver respuesta
Respuesta B.
st.cache_dataguarda datos serializables y devuelve una copia a cada llamada;st.cache_resourceguarda un objeto compartido, como una conexión, sin copiarloUn
DataFramede 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.El panel tiene que leer la clave API de un proveedor de datos. ¿Dónde va?
- AEn una constante al principio de
app.py - BEn
.streamlit/secrets.toml, que está en.gitignore, y se lee const.secrets - CEn el
README.mddel repositorio, para que el equipo la tenga - DEn un comentario del código
Ver respuesta
Respuesta B. En
.streamlit/secrets.toml, que está en.gitignore, y se lee const.secretsEl 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).- AEn una constante al principio de
¿Por qué las funciones de cálculo del panel están en
panel_datos.pyy no dentro deapp.py?- APorque Streamlit no admite funciones en
app.py - BPara que el panel cargue más rápido
- CPara poder probarlas con pytest o en un notebook sin arrancar la interfaz, y reutilizarlas en otros scripts
- DPorque
st.cache_datasolo funciona con funciones importadas
Ver respuesta
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.pyqueda como una capa fina que solo pinta.- APorque Streamlit no admite funciones en
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_dataevita repetir lo caro: guarda el resultado según los argumentos y devuelve una copia;ttldecide 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 localhostdeja el panel solo para tu equipo; las claves van en.streamlit/secrets.toml, fuera de Git.