Lección 0.5.4 · 45 min

Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas

Polars es una librería de tablas escrita en Rust que describe los cálculos como expresiones y deja que un optimizador decida cómo ejecutarlos. Esta lección repite con Polars los cálculos de las lecciones 0.5.2 y 0.5.3 sobre SINT, enseña a leer un plan de consulta y termina con una regla práctica para elegir entre Polars y pandas.

04/05
Módulo
0.5 · Computación numérica y datos
Duración
45 min
Lecciones del nivel 0
  1. 0.1 · Bienvenida y expectativas

    1. 0.1.1 Trading sistemático, algorítmico y cuantitativo frente al discrecional
    2. 0.1.2 Expectativas realistas: costes, tiempo y riesgo de pérdida
    3. 0.1.3 Estructura del curso, tests de entrada, repositorio personal y aprendizaje con IA
  2. 0.2 · Entorno de trabajo moderno

    1. 0.2.1 Instalación de Python con uv: versiones y ciclo de soporte
    2. 0.2.2 Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles
    3. 0.2.3 Editores (VS Code, Cursor, PyCharm) y notebooks (Jupyter, JupyterLab, marimo)
    4. 0.2.4 Terminal básica, rutas, variables de entorno y ficheros .env
    5. 0.2.5 Git y GitHub: commits, ramas, pull requests y .gitignore
    6. 0.2.6 Introducción a los asistentes de código con IA: prompts, revisión y tests
  3. 0.3 · Fundamentos del lenguaje

    1. 0.3.1 Variables, tipos y operadores; coma flotante y decimal para precios y ticks
    2. 0.3.2 Cadenas, f-strings y formato de números y fechas
    3. 0.3.3 Listas, tuplas, diccionarios, conjuntos y comprensiones
    4. 0.3.4 Control de flujo: if, elif, else, for, while y match-case
    5. 0.3.5 Funciones: argumentos, *args y **kwargs, lambdas y ámbito
    6. 0.3.6 Excepciones; módulos y paquetes, con los imports perezosos de Python 3.15
  4. 0.4 · Python intermedio

    1. 0.4.1 Programación orientada a objetos: clases, herencia, composición y dataclasses
    2. 0.4.2 Tipado: type hints, mypy o ty y Pydantic para configuraciones
    3. 0.4.3 Iteradores, generadores, decoradores y context managers
    4. 0.4.4 Ficheros (CSV, JSON, YAML, TOML) y fechas con zonas horarias
    5. 0.4.5 Logging profesional y testing con pytest
    6. 0.4.6 Concurrencia: threading, multiprocessing, asyncio y free-threading
  5. 0.5 · Computación numérica y datos

    1. 0.5.1 NumPy 2: arrays, broadcasting, vectorización, álgebra lineal y generadores aleatorios
    2. 0.5.2 pandas 3.0: Series, DataFrame, índices temporales, Copy-on-Write, tipo str y pd.col()
    3. 0.5.3 Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles
    4. 0.5.4 Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas
    5. 0.5.5 DuckDB y SQL esencial: SELECT, JOIN, GROUP BY y funciones de ventana sobre Parquet
  6. 0.6 · Visualización

    1. 0.6.1 Matplotlib orientado a objetos, Seaborn y Plotly: gráficos de precios, drawdown y velas
    2. 0.6.2 Dashboards rápidos con Streamlit: un panel de seguimiento en un solo script
  7. 0.7 · Matemáticas y estadística mínimas

    1. 0.7.1 Rentabilidades simples y logarítmicas, interés compuesto y estadística descriptiva
    2. 0.7.2 Probabilidad y distribuciones, correlación frente a causalidad y regresión simple
    3. 0.7.3 Álgebra lineal intuitiva: vectores y matrices como preparación para carteras

Otra forma de trabajar con tablas

pandas nació en 2008 sobre NumPy y conserva decisiones de entonces: un índice en cada tabla, operaciones que se ejecutan una a una en el orden en que se escriben y, en buena parte, un solo hilo. Polars, escrito en Rust y con el formato en memoria de Apache Arrow, parte de otras ideas:

pandasPolars
ÍndiceSí (DatetimeIndex, alineación)No: las fechas son una columna
Forma de escribirMétodos sobre Series y DataFrameExpresiones dentro de contextos
EjecuciónPaso a paso (eager)Eager o lazy (consulta optimizada)
ParalelismoMayoritariamente un hiloMultihilo por defecto
AusentesNaN (y NA en algunos tipos)null en todos los tipos

El curso usa Polars 2.0, publicado en octubre de 20261Nota 1 · Polars 2.0.0 en PyPI (pypi.org/project/polars/2.0.0), publicado el 6 de octubre de 2026; consultado el 21 de octubre de 2026. (versión fijada en curso-code/uv.lock). Las funciones que aparecen en esta lección ya existían en la serie 1.x: lo aprendido aquí sirve también para código algo más antiguo.

IN [01]python
import polars as pl

df = pl.read_csv("sint_diario.csv", try_parse_dates=True)
print(pl.__version__, df.shape)
print(df.schema)
df.select("fecha", "cierre", "volumen").head(3)
OUT [01]Las barras de SINT en Polars
2.0.0 (1303, 6)
Schema({'fecha': Date, 'apertura': Float64, 'maximo': Float64, 'minimo': Float64, 'cierre': Float64, 'volumen': Int64})
shape: (3, 3)
┌────────────┬────────┬─────────┐
│ fecha      ┆ cierre ┆ volumen │
│ ---        ┆ ---    ┆ ---     │
│ date       ┆ f64    ┆ i64     │
╞════════════╪════════╪═════════╡
│ 2021-01-04 ┆ 99.14  ┆ 2322609 │
│ 2021-01-05 ┆ 99.25  ┆ 1517954 │
│ 2021-01-06 ┆ 97.38  ┆ 3245658 │
└────────────┴────────┴─────────┘

try_parse_dates=True reconoce la columna de fechas, que pasa a ser de tipo Date (un día sin hora). No hay índice: la tabla tiene seis columnas, y fecha es una de ellas. Las filas se seleccionan por posición o con filtros, nunca por etiqueta.

Expresiones y contextos

La pieza central de Polars es la expresión: una descripción de un cálculo sobre columnas que todavía no se ha ejecutado. pl.col("cierre") es «la columna cierre», y pl.col("cierre").pct_change() es «la variación de la columna cierre respecto a la fila anterior». Una expresión se evalúa en un contexto:

ContextoQué haceEquivalente en pandas
select(...)Devuelve solo las columnas de las expresionesdf[[...]], agg
with_columns(...)Añade o sustituye columnasassign, pd.col()
filter(...)Conserva las filas en que la expresión es verdaderamáscara de bool
group_by(...).agg(...)Evalúa las expresiones dentro de cada grupogroupby().agg
IN [02]python
r = pl.col("cierre").pct_change()
print(r)

barras = df.with_columns(
    r=r,
    rango=(pl.col("maximo") - pl.col("minimo")) / pl.col("cierre"),
)
barras.select("fecha", "cierre", "r", "rango").head(3)
OUT [02]Columnas derivadas con with_columns
col("cierre").pct_change([dyn int: 1])
shape: (3, 4)
┌────────────┬────────┬───────────┬──────────┐
│ fecha      ┆ cierre ┆ r         ┆ rango    │
│ ---        ┆ ---    ┆ ---       ┆ ---      │
│ date       ┆ f64    ┆ f64       ┆ f64      │
╞════════════╪════════╪═══════════╪══════════╡
│ 2021-01-04 ┆ 99.14  ┆ null      ┆ 0.015836 │
│ 2021-01-05 ┆ 99.25  ┆ 0.00111   ┆ 0.011688 │
│ 2021-01-06 ┆ 97.38  ┆ -0.018841 ┆ 0.019203 │
└────────────┴────────┴───────────┴──────────┘

Impresa, r es solo la descripción del cálculo. Se puede guardar en una variable y reutilizar en varias consultas: es la misma idea que pd.col() de pandas 3.0 (lección 0.5.2). La primera rentabilidad sale null, el valor ausente de Polars, que existe igual en columnas de números, texto y fechas.

Dentro de un contexto, las expresiones se evalúan en paralelo y cada una produce una columna. Por eso cada resultado necesita un nombre propio: dos expresiones sobre r sin alias darían un error de nombre duplicado.

IN [03]python
caidas = barras.filter(
    (pl.col("r") < -0.03)
    & (pl.col("volumen") > pl.col("volumen").median())
)
print(caidas.height, "caídas fuertes con volumen alto")

barras.select(
    media=pl.col("r").mean(),
    vol_anual=pl.col("r").std() * 252 ** 0.5,
    sesiones=pl.col("r").count(),
)
OUT [03]Filtrar y agregar
10 caídas fuertes con volumen alto
shape: (1, 3)
┌──────────┬───────────┬──────────┐
│ media    ┆ vol_anual ┆ sesiones │
│ ---      ┆ ---       ┆ ---      │
│ f64      ┆ f64       ┆ u32      │
╞══════════╪═══════════╪══════════╡
│ 0.000342 ┆ 0.180962  ┆ 1302     │
└──────────┴───────────┴──────────┘

Las diez sesiones son las mismas que en la lección 0.5.3, y la mediana del volumen se calcula dentro del filtro sin variables auxiliares. count() no cuenta los null: 1.302 rentabilidades de 1.303 cierres.

group_by y ventanas

group_by funciona como en pandas, con la clave escrita también como expresión. El resultado de un group_by no conserva el orden de los grupos (se calculan en paralelo): para una tabla legible se ordena con sort o se pide maintain_order=True.

IN [04]python
por_ano = (
    barras.group_by(pl.col("fecha").dt.year().alias("ano"))
    .agg(
        n=pl.col("r").count(),
        anual=(1 + pl.col("r")).product() - 1,
        vol=pl.col("r").std() * 252 ** 0.5,
    )
    .sort("ano")
)
por_ano.with_columns(pl.col("anual", "vol").round(4))
OUT [04]Rentabilidad y volatilidad por año
shape: (5, 4)
┌──────┬─────┬────────┬────────┐
│ ano  ┆ n   ┆ anual  ┆ vol    │
│ ---  ┆ --- ┆ ---    ┆ ---    │
│ i32  ┆ u32 ┆ f64    ┆ f64    │
╞══════╪═════╪════════╪════════╡
│ 2021 ┆ 259 ┆ 0.152  ┆ 0.1599 │
│ 2022 ┆ 260 ┆ -0.085 ┆ 0.2483 │
│ 2023 ┆ 260 ┆ 0.1421 ┆ 0.1495 │
│ 2024 ┆ 262 ┆ 0.1432 ┆ 0.1499 │
│ 2025 ┆ 261 ┆ 0.0424 ┆ 0.1792 │
└──────┴─────┴────────┴────────┘

Las cifras coinciden con las de pandas en la lección 0.5.3: −8,5 % en 2022 con una volatilidad del 24,8 %. pl.col admite varios nombres a la vez, y la expresión se aplica a cada columna.

Las ventanas móviles y acumuladas son métodos de las expresiones. La volatilidad de 21 sesiones y el drawdown, en un solo contexto:

IN [05]python
riesgo = barras.with_columns(
    vol_21=pl.col("r").rolling_std(21) * 252 ** 0.5,
    dd=pl.col("cierre") / pl.col("cierre").cum_max() - 1,
)
riesgo.select(
    vol_max=pl.col("vol_21").max(),
    sin_ventana=pl.col("vol_21").null_count(),
    peor_dd=pl.col("dd").min(),
    fecha_fondo=pl.col("fecha").get(pl.col("dd").arg_min()),
)
OUT [05]Volatilidad móvil y drawdown
shape: (1, 4)
┌──────────┬─────────────┬───────────┬─────────────┐
│ vol_max  ┆ sin_ventana ┆ peor_dd   ┆ fecha_fondo │
│ ---      ┆ ---         ┆ ---       ┆ ---         │
│ f64      ┆ u32         ┆ f64       ┆ date        │
╞══════════╪═════════════╪═══════════╪═════════════╡
│ 0.339454 ┆ 21          ┆ -0.250278 ┆ 2022-10-20  │
└──────────┴─────────────┴───────────┴─────────────┘

Volatilidad máxima del 33,9 %, 21 valores sin ventana completa y un drawdown del 25 % con fondo el 20 de octubre de 2022: los mismos números que en pandas. Polars no tiene índice, así que «la fecha del mínimo» se escribe como una expresión: la fecha en la posición del mínimo del drawdown.

Los cálculos por grupo que conservan todas las filas usan over, el equivalente de las funciones de ventana de SQL (lección 0.5.5). En el libro de operaciones, el peso de cada operación en el resultado de su símbolo:

IN [06]python
ops = pl.read_csv("operaciones_sint.csv", try_parse_dates=True)
signo = pl.when(pl.col("lado") == "largo").then(1).otherwise(-1)
pnl = (signo * (pl.col("precio_salida") - pl.col("precio_entrada"))
       * pl.col("cantidad") - pl.col("comision"))
ops = ops.with_columns(pnl=pnl).with_columns(
    n_simbolo=pl.len().over("simbolo"),
    total_simbolo=pl.col("pnl").sum().over("simbolo"),
)
ops.select("id", "simbolo", "pnl", "n_simbolo", "total_simbolo").head(4)
OUT [06]Valores por grupo con over
shape: (4, 5)
┌─────┬─────────┬──────────┬───────────┬───────────────┐
│ id  ┆ simbolo ┆ pnl      ┆ n_simbolo ┆ total_simbolo │
│ --- ┆ ---     ┆ ---      ┆ ---       ┆ ---           │
│ i64 ┆ str     ┆ f64      ┆ u32       ┆ f64           │
╞═════╪═════════╪══════════╪═══════════╪═══════════════╡
│ 1   ┆ ORO     ┆ -3123.56 ┆ 3         ┆ -3237.96      │
│ 2   ┆ SINT    ┆ 7.43     ┆ 30        ┆ 4405.08       │
│ 3   ┆ SINT    ┆ -31.5    ┆ 30        ┆ 4405.08       │
│ 4   ┆ SINT    ┆ -306.72  ┆ 30        ┆ 4405.08       │
└─────┴─────────┴──────────┴───────────┴───────────────┘

pl.when(...).then(...).otherwise(...) es el np.where de las expresiones. Con over("simbolo"), la suma se calcula dentro de cada símbolo y se repite en todas sus filas: la operación 1, de ORO, ve el total de las tres operaciones de ORO. Con group_by habría una fila por símbolo; con over, se conservan las 40.

Lazy frente a eager

Todo lo anterior es eager: cada with_columns o filter se ejecuta al momento y devuelve un DataFrame. La API lazy cambia el orden: scan_parquet (o scan_csv, o df.lazy()) devuelve un LazyFrame, que solo acumula la consulta. Nada se lee ni se calcula hasta collect(), y antes de ejecutar, el optimizador reescribe el plan completo:

scan_parquetfilterwith_columnscollect
Fuente Mermaid
flowchart LR
  A[scan_parquet] --> B[filter]
  B --> C[with_columns]
  C --> D[collect]
IN [07]python
consulta = (
    pl.scan_parquet("sint_diario.parquet")
    .filter(pl.col("fecha").dt.year() == 2022)
    .with_columns(r=pl.col("cierre").pct_change())
    .select("fecha", "r")
)
print(type(consulta).__name__)
print(consulta.explain())
OUT [07]Una consulta lazy sobre Parquet
LazyFrame
simple π 2/2 ["fecha", "r"]
   WITH_COLUMNS:
   [col("cierre").pct_change([dyn int: 1]).alias("r")]
    Parquet SCAN [sint_diario.parquet]
    PROJECT 2/6 COLUMNS
    SELECTION: (col("fecha").dt.year() == 2022)
    ESTIMATED ROWS: 1303

El plan se lee de abajo arriba. El optimizador ha hecho dos cosas que en eager no son posibles:

  • Proyección empujada (PROJECT 2/6 COLUMNS): la consulta solo usa fecha y cierre, así que el lector de Parquet no carga las otras cuatro columnas del disco.
  • Filtro empujado (SELECTION): la condición del año se aplica mientras se lee el fichero, y Parquet puede saltarse bloques enteros que no cumplen la condición gracias a las estadísticas que guarda de cada uno.

Con un fichero de 50 KB no se nota. Con años de barras de un minuto de cientos de símbolos, la diferencia está entre leer unos cientos de megas y leer decenas de gigas. Este plan, sin embargo, tiene un problema que no es de rendimiento:

IN [08]python
antes = consulta.collect()
despues = (
    pl.scan_parquet("sint_diario.parquet")
    .with_columns(r=pl.col("cierre").pct_change())
    .filter(pl.col("fecha").dt.year() == 2022)
    .select("fecha", "r")
    .collect()
)
print(antes.head(2))
print(despues.head(2))
OUT [08]El orden importa: filtrar antes o después
shape: (2, 2)
┌─────────────────────┬───────────┐
│ fecha               ┆ r         │
│ ---                 ┆ ---       │
│ datetime[μs]        ┆ f64       │
╞═════════════════════╪═══════════╡
│ 2022-01-03 00:00:00 ┆ null      │
│ 2022-01-04 00:00:00 ┆ -0.017069 │
└─────────────────────┴───────────┘
shape: (2, 2)
┌─────────────────────┬───────────┐
│ fecha               ┆ r         │
│ ---                 ┆ ---       │
│ datetime[μs]        ┆ f64       │
╞═════════════════════╪═══════════╡
│ 2022-01-03 00:00:00 ┆ 0.000263  │
│ 2022-01-04 00:00:00 ┆ -0.017069 │
└─────────────────────┴───────────┘

En la primera consulta, el filtro se aplica antes de pct_change: el 3 de enero de 2022 ya no tiene el cierre del 31 de diciembre y su rentabilidad sale null. En la segunda, la rentabilidad se calcula con toda la serie y después se filtra: el 3 de enero tiene su 0,03 %. El optimizador no cambia el orden en este caso, porque pct_change depende de la fila anterior y el resultado sería otro; la responsabilidad de escribir el orden correcto es de quien escribe la consulta. El error, en sentido contrario, es más grave: cualquier cálculo que mire hacia atrás (shift, ventanas, máximos acumulados) debe hacerse con la historia completa antes de recortar el periodo de un backtest.

¿Cuándo usar cada modo? Eager es cómodo para explorar en un notebook: cada paso muestra su resultado. Lazy conviene en cuanto los datos vienen de ficheros grandes o la consulta tiene varios pasos, y es el modo natural de los scripts que procesan datos de forma periódica (la descarga diaria de barras, el recálculo nocturno de señales). Una consulta lazy termina en collect(), que devuelve un DataFrame, o en sink_parquet("salida.parquet"), que escribe el resultado en disco sin reunirlo entero en memoria. Y df.lazy() convierte un DataFrame ya cargado en un LazyFrame, para aprovechar el optimizador en una cadena larga de operaciones.

fecha aparece como datetime[μs] y no como Date: el Parquet de los datos lo guardó pandas, que escribe las fechas con hora. Leer el mismo dato de dos fuentes y comparar los tipos es un control que evita uniones que no casan.

Rendimiento sin cronómetro

Esta lección no da tiempos: dependen de la máquina, de la versión y del tamaño de los datos, y los de un fichero de 1.303 filas no dicen nada. Lo que sí se puede afirmar es por qué Polars suele ser más rápido con datos grandes, y cuándo deja de serlo:

  • Las expresiones se ejecutan en Rust, en varios hilos y sobre columnas contiguas de Arrow, sin un objeto de Python por valor.
  • En lazy, el optimizador elimina columnas y filas innecesarias antes de leerlas y reutiliza subexpresiones comunes.
  • Con el motor de streaming, una consulta lazy procesa ficheros más grandes que la memoria por bloques.
  • Una función de Python dentro de una expresión (map_elements) devuelve el cálculo a Python fila a fila y anula casi todas las ventajas. Si una operación no existe como expresión, suele haber una combinación de expresiones que la hace.

Polars y pandas juntos

La mayoría de los proyectos usan las dos. Las conversiones son directas, con dos detalles: pandas necesita un índice y los tipos de fecha cambian de resolución.

IN [09]python
pdf = df.to_pandas().set_index("fecha")
print(type(pdf).__name__, pdf.index.dtype, pdf.shape)
de_vuelta = pl.from_pandas(pdf, include_index=True)
print(de_vuelta.schema["fecha"], de_vuelta.shape)
OUT [09]De Polars a pandas y vuelta
DataFrame datetime64[ms] (1303, 5)
Datetime(time_unit='ms', time_zone=None) (1303, 6)

El Date de Polars llega a pandas como datetime64[ms] y vuelve como Datetime, no como Date. Si el resto del código compara fechas entre tablas, conviene normalizar el tipo en un único sitio (por ejemplo, con pl.col("fecha").cast(pl.Date)). La conversión usa Arrow por debajo y, para tipos numéricos, es rápida; no es gratis con tablas grandes, así que se hace una vez, en la frontera entre las dos partes del código.

SituaciónElecciónPor qué
Ficheros Parquet grandes, filtros y agregacionesPolars lazyProyección y filtro empujados, multihilo
Barras intradía de muchos símbolos por grupoPolarsgroup_by y over en paralelo
Series temporales con índice, resample, alineaciónpandasEl índice hace el trabajo
statsmodels, scikit-learn, librerías de backtestingpandas (o NumPy)Es lo que esperan
Tablas de cientos o miles de filasLa que ya use el proyectoLa diferencia no se nota

La lección 0.5.5 añade una tercera opción para las mismas consultas: SQL sobre Parquet con DuckDB.

Ejercicios

  1. Con group_by_dynamic("fecha", every="1mo"), calcula el último cierre de cada mes de SINT y su rentabilidad mensual. Compara la etiqueta de cada mes con la de resample("ME") de pandas (lección 0.5.3). ¿Qué riesgo tiene esa etiqueta?
Solución
IN [10]python
mensual = (
    df.group_by_dynamic("fecha", every="1mo")
    .agg(pl.col("cierre").last())
    .with_columns(r=pl.col("cierre").pct_change())
)
print(mensual.height)
mensual.head(3)
OUT [10]Rentabilidades mensuales en Polars
60
shape: (3, 3)
┌────────────┬────────┬──────────┐
│ fecha      ┆ cierre ┆ r        │
│ ---        ┆ ---    ┆ ---      │
│ date       ┆ f64    ┆ f64      │
╞════════════╪════════╪══════════╡
│ 2021-01-01 ┆ 95.99  ┆ null     │
│ 2021-02-01 ┆ 97.65  ┆ 0.017293 │
│ 2021-03-01 ┆ 103.27 ┆ 0.057552 │
└────────────┴────────┴──────────┘

Los valores coinciden con los de pandas (1,73 % en febrero de 2021), pero la etiqueta es el inicio del mes (2021-02-01) y el valor es el cierre del final. Si esa tabla se cruza por fecha con datos diarios, el 1 de febrero «conocería» el cierre del 26 de febrero: anticipación. Lo seguro es desplazar la etiqueta al final del periodo o usar el resultado solo a partir del mes siguiente.

  1. Escribe una consulta lazy sobre sint_diario.parquet que devuelva la rentabilidad anual de cada año (compuesta a partir de las rentabilidades diarias, calculadas con toda la serie) y comprueba con explain() cuántas columnas se leen del fichero.
Solución
IN [11]python
anual = (
    pl.scan_parquet("sint_diario.parquet")
    .with_columns(r=pl.col("cierre").pct_change())
    .group_by(pl.col("fecha").dt.year().alias("ano"))
    .agg(anual=(1 + pl.col("r")).product() - 1)
    .sort("ano")
)
print("PROJECT 2/6" in anual.explain())
anual.collect().with_columns(pl.col("anual").round(4))
OUT [11]Rentabilidad anual en lazy
True
shape: (5, 2)
┌──────┬────────┐
│ ano  ┆ anual  │
│ ---  ┆ ---    │
│ i32  ┆ f64    │
╞══════╪════════╡
│ 2021 ┆ 0.152  │
│ 2022 ┆ -0.085 │
│ 2023 ┆ 0.1421 │
│ 2024 ┆ 0.1432 │
│ 2025 ┆ 0.0424 │
└──────┴────────┘

Solo se leen fecha y cierre. product() ignora el null de la primera fila, así que 2021 compone sus 259 rentabilidades. Las cifras son las mismas que con pandas y que en el group_by eager.

  1. Con ops (que ya tiene pnl), calcula para cada operación la diferencia entre su resultado y la media de las operaciones de su mismo símbolo, sin perder filas. Muestra las tres operaciones de TECH.
Solución
IN [12]python
(
    ops.with_columns(
        frente_media=pl.col("pnl")
        - pl.col("pnl").mean().over("simbolo"),
    )
    .filter(pl.col("simbolo") == "TECH")
    .select("id", "pnl", "frente_media")
)
OUT [12]Resultado frente a la media del símbolo
shape: (3, 3)
┌─────┬─────────┬──────────────┐
│ id  ┆ pnl     ┆ frente_media │
│ --- ┆ ---     ┆ ---          │
│ i64 ┆ f64     ┆ f64          │
╞═════╪═════════╪══════════════╡
│ 10  ┆ 239.14  ┆ 250.813333   │
│ 18  ┆ -47.0   ┆ -35.326667   │
│ 38  ┆ -227.16 ┆ -215.486667  │
└─────┴─────────┴──────────────┘

over calcula la media dentro de cada símbolo y la repite en sus filas; la resta se hace fila a fila. Es el patrón de cualquier normalización por grupo (rentabilidad frente a la media del sector, volumen frente a la media del símbolo).

Footnotes

  1. Polars 2.0.0 en PyPI (pypi.org/project/polars/2.0.0), publicado el 6 de octubre de 2026; consultado el 21 de octubre de 2026. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. ¿Qué devuelve pl.col("cierre").pct_change() si se evalúa sola, fuera de un DataFrame?

    • AUna Series con las rentabilidades de SINT
    • BUna expresión: la descripción de un cálculo que se ejecutará cuando se use dentro de select, with_columns o filter
    • CUn error, porque no se ha leído ningún fichero
    • DUn número: la rentabilidad media
    Ver respuesta

    Respuesta B. Una expresión: la descripción de un cálculo que se ejecutará cuando se use dentro de select, with_columns o filter

    Las expresiones de Polars no contienen datos. Se pueden guardar en variables y combinar, y solo se evalúan en un contexto (select, with_columns, filter, group_by().agg).

  2. En una consulta lazy, ¿qué significa PROJECT 2/6 COLUMNS dentro de Parquet SCAN en la salida de explain()?

    • AQue el fichero tiene dos columnas dañadas
    • BQue solo se leerán del fichero las 2 columnas que la consulta necesita, de 6
    • CQue la consulta devolverá 2 filas de cada 6
    • DQue se usarán 2 hilos de 6
    Ver respuesta

    Respuesta B. Que solo se leerán del fichero las 2 columnas que la consulta necesita, de 6

    Es la proyección empujada al lector (*projection pushdown*): Polars analiza la consulta completa y no lee del disco las columnas que no se usan. Con ficheros grandes, es la mayor parte del ahorro.

  3. Se quiere la rentabilidad diaria de SINT en 2022. ¿Por qué filtrar 2022 → pct_change y pct_change → filtrar 2022 dan resultados distintos?

    • ANo dan resultados distintos: el optimizador los iguala
    • BPorque en la primera, la rentabilidad del primer día de 2022 no tiene el cierre del 31 de diciembre de 2021 y sale nula
    • CPorque la segunda lee el fichero dos veces
    • DPorque pct_change no funciona en modo lazy
    Ver respuesta

    Respuesta B. Porque en la primera, la rentabilidad del primer día de 2022 no tiene el cierre del 31 de diciembre de 2021 y sale nula

    pct_change depende de la fila anterior. Si el filtro va antes, la fila anterior al 3 de enero de 2022 ha desaparecido. El optimizador respeta el orden cuando cambiar el orden cambiaría el resultado.

  4. ¿En cuál de estos casos tiene más sentido Polars que pandas?

    • AUn notebook que usa statsmodels y una librería de backtesting que espera un DataFrame de pandas con DatetimeIndex
    • BAgregar barras de un minuto de cientos de símbolos guardadas en decenas de ficheros Parquet
    • CUna tabla de 40 operaciones
    • DCualquier caso: Polars siempre es mejor
    Ver respuesta

    Respuesta B. Agregar barras de un minuto de cientos de símbolos guardadas en decenas de ficheros Parquet

    Polars gana con datos grandes y consultas que filtran y agregan, sobre todo en lazy y sobre Parquet. Con tablas pequeñas o código que depende del ecosistema de pandas, la conversión y el cambio de API no compensan.

Ideas clave

  • Polars no tiene índice: las fechas son una columna más. Los cálculos se escriben como expresiones (pl.col(...)) dentro de select, with_columns, filter y group_by().agg.
  • Eager (read_*) ejecuta cada paso al momento; lazy (scan_*) acumula la consulta y la ejecuta entera con collect(), después de optimizarla.
  • explain() muestra el plan: el filtro y las columnas que se empujan al lector de Parquet son lo que más ahorra en ficheros grandes.
  • El orden de las operaciones importa con cálculos que miran a la fila anterior (pct_change, shift, ventanas): filtrar antes puede romperlos.
  • Polars para datos grandes y consultas de agregación; pandas cuando el resto del código lo espera. to_pandas() y pl.from_pandas() conectan los dos.

Recursos

Datos