Lección 0.5.5 · 50 min

DuckDB y SQL esencial: SELECT, JOIN, GROUP BY y funciones de ventana sobre Parquet

SQL es el idioma común de los datos: lo hablan las bases de datos de los brókers, los almacenes de datos y los motores de consulta. DuckDB lo ejecuta dentro de Python, sin servidor, directamente sobre ficheros Parquet y CSV. Esta lección enseña el SQL que hace falta para research con los datos de SINT, con especial cuidado en las ventanas, donde es fácil usar el futuro sin darse cuenta.

05/05
Módulo
0.5 · Computación numérica y datos
Duración
50 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

SQL sin servidor

SQL (Structured Query Language) describe qué datos se quieren, no cómo obtenerlos: el motor decide el orden de lectura, los índices y los algoritmos. Es el lenguaje de las bases de datos de órdenes y posiciones de los brókers, de los almacenes de datos de mercado y de buena parte de las herramientas de research en la nube. Saber leerlo y escribirlo es tan básico como pandas.

Para usarlo hace falta un motor. Las bases de datos clásicas (PostgreSQL, MySQL) son un servidor aparte al que el programa se conecta. DuckDB es otra cosa: una base de datos analítica que se instala como una librería (uv add duckdb) y se ejecuta dentro del proceso de Python, como SQLite, pero diseñada para consultas que leen y agregan millones de filas por columnas. Y lee directamente los ficheros: un Parquet o un CSV se consultan por su ruta, sin importarlos antes.

La versión del curso es DuckDB 1.5.6 (fijada en curso-code/uv.lock).

IN [01]python
import duckdb

con = duckdb.connect()   # base de datos en memoria
print(duckdb.__version__)
con.sql("""
    SELECT fecha, cierre, volumen
    FROM 'sint_diario.parquet'
    ORDER BY fecha
    LIMIT 3
""")
OUT [01]Primera consulta sobre Parquet
1.5.6
┌─────────────────────┬────────┬─────────┐
│        fecha        │ cierre │ volumen │
│      timestamp      │ double │  int64  │
├─────────────────────┼────────┼─────────┤
│ 2021-01-04 00:00:00 │  99.14 │ 2322609 │
│ 2021-01-05 00:00:00 │  99.25 │ 1517954 │
│ 2021-01-06 00:00:00 │  97.38 │ 3245658 │
└─────────────────────┴────────┴─────────┘

duckdb.connect() sin argumentos crea una base de datos en memoria, que desaparece al terminar; con una ruta (duckdb.connect("research.duckdb")) la guarda en un fichero. con.sql devuelve una relación: el resultado de la consulta, que se imprime como tabla y se puede convertir a otros formatos. Debajo de cada columna aparece su tipo SQL: timestamp (fecha con hora), double (número de coma flotante) y int64.

SELECT, WHERE y ORDER BY

Una consulta tiene siempre la misma estructura. Lo que cambia con la experiencia es saber que el orden en que se escribe no es el orden en que se ejecuta:

FROM y JOINWHEREGROUP BY y HAVINGSELECT y ventanasORDER BY y LIMIT
Fuente Mermaid
flowchart TB
  A[FROM y JOIN] --> B[WHERE]
  B --> C[GROUP BY y HAVING]
  C --> D[SELECT y ventanas]
  D --> E[ORDER BY y LIMIT]

Primero se leen y combinan las tablas, después se descartan filas con WHERE, se agrupan, se calculan las columnas del SELECT y al final se ordena y se recorta. Ese orden explica errores frecuentes: WHERE no puede usar un alias definido en el SELECT ni el resultado de una función de ventana, porque todavía no existen cuando se evalúa.

IN [02]python
con.sql("""
    SELECT fecha::DATE AS dia, cierre, volumen
    FROM 'sint_diario.parquet'
    WHERE year(fecha) = 2022 AND volumen > 3500000
    ORDER BY volumen DESC
    LIMIT 5
""")
OUT [02]Sesiones de 2022 con más volumen
┌────────────┬────────┬─────────┐
│    dia     │ cierre │ volumen │
│    date    │ double │  int64  │
├────────────┼────────┼─────────┤
│ 2022-04-22 │  99.38 │ 5400080 │
│ 2022-08-26 │ 112.63 │ 5010487 │
│ 2022-11-28 │  96.98 │ 4662485 │
│ 2022-05-23 │ 109.61 │ 4342762 │
│ 2022-12-05 │  99.44 │ 3676322 │
└────────────┴────────┴─────────┘

fecha::DATE convierte la marca de tiempo en fecha (el Parquet la guardó con hora, como se vio en la lección 0.5.4) y AS dia da nombre a la columna resultante. year() es una de las funciones de fecha de DuckDB. Las condiciones se combinan con AND, OR y NOT, sin los paréntesis obligatorios de pandas.

DuckDB lee del Parquet solo las columnas y los bloques que la consulta necesita, igual que el modo lazy de Polars: EXPLAIN antes de una consulta muestra el plan, con las columnas proyectadas (Projections) y los filtros empujados al lector (Filters).

GROUP BY: resúmenes por año

GROUP BY agrupa las filas con el mismo valor de la clave y las funciones de agregación (count, min, max, avg, sum…) reducen cada grupo a una fila. Toda columna del SELECT debe estar en el GROUP BY o dentro de una agregación:

IN [03]python
con.sql("""
    SELECT year(fecha)               AS ano,
           count(*)                  AS sesiones,
           round(min(cierre), 2)     AS minimo,
           round(max(cierre), 2)     AS maximo,
           arg_max(cierre, fecha)    AS ultimo,
           round(avg(volumen) / 1e6, 2) AS vol_medio_m
    FROM 'sint_diario.parquet'
    GROUP BY ano
    ORDER BY ano
""")
OUT [03]Resumen anual de SINT
┌───────┬──────────┬────────┬────────┬────────┬─────────────┐
│  ano  │ sesiones │ minimo │ maximo │ ultimo │ vol_medio_m │
│ int64 │  int64   │ double │ double │ double │   double    │
├───────┼──────────┼────────┼────────┼────────┼─────────────┤
│  2021 │      260 │  95.38 │ 115.78 │ 114.21 │        1.89 │
│  2022 │      260 │  94.45 │ 125.98 │  104.5 │         1.9 │
│  2023 │      260 │ 101.72 │  121.8 │ 119.35 │         1.8 │
│  2024 │      262 │ 118.36 │ 137.15 │ 136.44 │        1.87 │
│  2025 │      261 │ 122.38 │ 149.74 │ 142.22 │        1.74 │
└───────┴──────────┴────────┴────────┴────────┴─────────────┘

arg_max(cierre, fecha) devuelve el cierre de la fila con la fecha máxima del grupo: el último cierre de cada año. Es una función de DuckDB muy útil con series temporales, y evita una subconsulta. 2022 tiene el máximo más alto de los tres primeros años (125,98) y también el mínimo más bajo de todo el periodo (94,45): el año de la caída. DuckDB permite usar el alias ano en el GROUP BY, una comodidad que no todos los motores SQL admiten.

HAVING filtra después de agrupar, sobre los resultados de las agregaciones. Para quedarse con los años cuyo mínimo bajó de 100, la condición va en HAVING min(cierre) < 100, no en WHERE.

JOIN: operaciones y precios

JOIN combina filas de dos tablas que cumplen una condición, como merge en pandas (lección 0.5.3). Las dos tablas pueden venir de ficheros distintos, incluso de formatos distintos: el libro de operaciones está en CSV y los precios en Parquet.

IN [04]python
con.sql("""
    SELECT o.id, o.fecha_entrada, o.lado,
           o.precio_entrada, p.cierre, p.volumen
    FROM 'operaciones_sint.csv' AS o
    JOIN 'sint_diario.parquet' AS p
      ON p.fecha = o.fecha_entrada
    WHERE o.simbolo = 'SINT'
    ORDER BY o.id
    LIMIT 3
""")
OUT [04]Cada operación con el precio de su día de entrada
┌───────┬───────────────┬─────────┬────────────────┬────────┬─────────┐
│  id   │ fecha_entrada │  lado   │ precio_entrada │ cierre │ volumen │
│ int64 │     date      │ varchar │     double     │ double │  int64  │
├───────┼───────────────┼─────────┼────────────────┼────────┼─────────┤
│     2 │ 2021-09-02    │ largo   │         108.64 │ 108.64 │ 1454984 │
│     3 │ 2021-10-27    │ corto   │         112.07 │ 112.07 │ 1644654 │
│     4 │ 2021-11-10    │ corto   │          104.3 │  104.3 │ 2575784 │
└───────┴───────────────┴─────────┴────────────────┴────────┴─────────┘

AS o y AS p son alias para no repetir los nombres de los ficheros. La condición ON compara una fecha del CSV (DATE) con una marca de tiempo del Parquet (TIMESTAMP); DuckDB convierte la primera a medianoche y la comparación funciona. Con marcas de tiempo con hora, la misma unión dejaría de casar sin dar error: comprueba siempre los tipos de las claves.

JOINFilas que conservaEquivale en pandas a
JOIN (o INNER JOIN)Solo las que tienen parejahow="inner"
LEFT JOINTodas las de la izquierdahow="left"
FULL JOINTodas las de los dos ladoshow="outer"

La agregación funciona sobre el resultado de la unión como sobre cualquier tabla. El resultado neto de cada símbolo, con el signo de la operación escrito con CASE:

IN [05]python
con.sql("""
    SELECT simbolo,
           count(*) AS operaciones,
           round(sum(
               CASE WHEN lado = 'largo' THEN 1 ELSE -1 END
               * (precio_salida - precio_entrada) * cantidad
               - comision), 2) AS total
    FROM 'operaciones_sint.csv'
    GROUP BY simbolo
    ORDER BY total DESC
""")
OUT [05]Resultado por símbolo con CASE y GROUP BY
┌─────────┬─────────────┬──────────┐
│ simbolo │ operaciones │  total   │
│ varchar │    int64    │  double  │
├─────────┼─────────────┼──────────┤
│ SINT    │          30 │  4405.08 │
│ RVAR    │           4 │    17.09 │
│ TECH    │           3 │   -35.02 │
│ ORO     │           3 │ -3237.96 │
└─────────┴─────────────┴──────────┘

Los totales son los mismos que con pandas y Polars en las lecciones anteriores: SINT gana 4.405,08 y ORO pierde 3.237,96. Tres herramientas distintas, el mismo número: cuando se reescribe un cálculo en otra herramienta, comparar el resultado con el de la original es la prueba más barata de que no se ha colado un error.

Funciones de ventana

Una función de ventana calcula, para cada fila, un valor a partir de otras filas relacionadas, sin agruparlas: la tabla conserva todas sus filas. Se escribe con OVER (...), que define tres cosas:

  • PARTITION BY: los grupos independientes (por ejemplo, un símbolo). Sin él, toda la tabla es una partición.
  • ORDER BY: el orden de las filas dentro de la partición. En series temporales, siempre la fecha.
  • El marco (ROWS BETWEEN … AND …): qué filas, relativas a la actual, entran en el cálculo.

La rentabilidad diaria necesita el cierre anterior. LAG(cierre) lo trae a la fila actual:

IN [06]python
con.sql("""
    CREATE TABLE sint AS
    SELECT fecha::DATE AS fecha, cierre,
           cierre / LAG(cierre) OVER (ORDER BY fecha) - 1 AS r
    FROM 'sint_diario.parquet'
""")
con.sql("""
    SELECT fecha, cierre, round(r, 6) AS r
    FROM sint ORDER BY fecha LIMIT 3
""")
OUT [06]Rentabilidades con LAG
┌────────────┬────────┬───────────┐
│   fecha    │ cierre │     r     │
│    date    │ double │  double   │
├────────────┼────────┼───────────┤
│ 2021-01-04 │  99.14 │      NULL │
│ 2021-01-05 │  99.25 │   0.00111 │
│ 2021-01-06 │  97.38 │ -0.018841 │
└────────────┴────────┴───────────┘

CREATE TABLE ... AS guarda el resultado en una tabla de la base de datos en memoria, para no repetir la lectura en las consultas siguientes. La primera fila no tiene cierre anterior y su rentabilidad es NULL, como el NaN de pandas. LAG solo mira hacia atrás; su pareja, LEAD, mira hacia delante y es la forma más directa de introducir anticipación en una señal.

Medias móviles con marco explícito

Una media móvil de 50 sesiones es la media de la fila actual y las 49 anteriores. Se escribe con un marco ROWS explícito, que cuenta filas (sesiones) y no fechas:

IN [07]python
con.sql("""
    SELECT row_number() OVER (ORDER BY fecha) AS fila,
           fecha, cierre,
           round(AVG(cierre) OVER w, 2) AS media_50,
           COUNT(cierre) OVER w AS n
    FROM sint
    WINDOW w AS (ORDER BY fecha
                 ROWS BETWEEN 49 PRECEDING AND CURRENT ROW)
    QUALIFY fila IN (1, 2, 49, 50, 51)
    ORDER BY fecha
""")
OUT [07]Media móvil de 50 sesiones
┌───────┬────────────┬────────┬──────────┬───────┐
│ fila  │   fecha    │ cierre │ media_50 │   n   │
│ int64 │    date    │ double │  double  │ int64 │
├───────┼────────────┼────────┼──────────┼───────┤
│     1 │ 2021-01-04 │  99.14 │    99.14 │     1 │
│     2 │ 2021-01-05 │  99.25 │     99.2 │     2 │
│    49 │ 2021-03-11 │ 101.76 │     97.6 │    49 │
│    50 │ 2021-03-12 │ 104.72 │    97.74 │    50 │
│    51 │ 2021-03-15 │ 106.21 │    97.88 │    50 │
└───────┴────────────┴────────┴──────────┴───────┘

WINDOW w AS (...) define el marco una vez para varias columnas, y row_number() numera las filas para mostrar solo unas cuantas con QUALIFY (el filtro que se aplica después de las ventanas; se explica más abajo). La columna n revela la diferencia más importante con rolling(50) de pandas: SQL calcula con las filas que haya en el marco. La primera fila tiene una «media de 50 sesiones» que es su propio cierre, y la 49 promedia 49 cierres; pandas devolvería NaN en las 49 primeras. Desde la fila 50 (97,74) los dos coinciden. Para obtener un nulo con ventanas incompletas, se envuelve en CASE WHEN COUNT(cierre) OVER w = 50 THEN AVG(cierre) OVER w END, o se filtra por n como en el ejercicio 3.

La diferencia entre un marco que mira al pasado y uno que usa toda la tabla se ve en una sola fila:

IN [08]python
con.sql("""
    SELECT fecha, cierre,
           round(AVG(cierre) OVER (), 2) AS media_toda,
           round(AVG(cierre) OVER (
               ORDER BY fecha
               ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
           ), 2) AS media_hasta_hoy
    FROM sint
    QUALIFY fecha = DATE '2021-06-01'
""")
OUT [08]Ventana sin marco: anticipación
┌────────────┬────────┬────────────┬─────────────────┐
│   fecha    │ cierre │ media_toda │ media_hasta_hoy │
│    date    │ double │   double   │     double      │
├────────────┼────────┼────────────┼─────────────────┤
│ 2021-06-01 │  99.56 │     117.26 │           100.2 │
└────────────┴────────┴────────────┴─────────────────┘

El 1 de junio de 2021, la media de toda la serie (117,26) incluye cuatro años y medio de precios futuros y está muy por encima de la media real hasta ese día (100,2). Una regla como «comprar si el cierre está por debajo de su media» parecería excelente con la primera columna en un activo que sube, y no se podría operar. QUALIFY filtra después de calcular las ventanas: con WHERE, el filtro se aplicaría antes y la media se calcularía sobre una sola fila.

Máximo acumulado y drawdown

El drawdown necesita el máximo de todos los cierres hasta hoy: un marco que empieza en UNBOUNDED PRECEDING. Y la volatilidad móvil, la desviación típica de las rentabilidades en un marco de 21 filas:

IN [09]python
riesgo = con.sql("""
    SELECT fecha, cierre,
           cierre / MAX(cierre) OVER (
               ORDER BY fecha
               ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
           ) - 1 AS dd,
           STDDEV_SAMP(r) OVER (
               ORDER BY fecha
               ROWS BETWEEN 20 PRECEDING AND CURRENT ROW
           ) * sqrt(252) AS vol_21
    FROM sint
""")
con.sql("""
    SELECT round(min(dd), 4) AS peor_dd,
           arg_min(fecha, dd) AS fondo,
           round(max(vol_21), 4) AS vol_max
    FROM riesgo
""")
OUT [09]Drawdown y volatilidad con ventanas
┌─────────┬────────────┬─────────┐
│ peor_dd │   fondo    │ vol_max │
│ double  │    date    │ double  │
├─────────┼────────────┼─────────┤
│ -0.2503 │ 2022-10-20 │  0.3395 │
└─────────┴────────────┴─────────┘

El mismo −25,0 % con fondo el 20 de octubre de 2022 y la misma volatilidad máxima del 33,9 % que en pandas y Polars. Una relación se puede consultar por el nombre de su variable de Python (FROM riesgo): DuckDB encadena las dos consultas sin materializar la primera. Como en la media móvil, las primeras filas tienen una volatilidad calculada con menos de 21 rentabilidades (STDDEV_SAMP ignora el NULL inicial), donde pandas daba NaN; el máximo, lejos del principio, es el mismo.

De SQL a pandas y Polars, y vuelta

Una consulta de DuckDB termina normalmente en un DataFrame. Los métodos de la relación lo hacen directamente:

IN [10]python
import pandas as pd

df_pd = riesgo.df()
df_pl = riesgo.pl()
print(type(df_pd).__name__, df_pd.shape, df_pd["fecha"].dtype)
print(type(df_pl).__name__, df_pl.shape, df_pl.schema["fecha"])

precios = pd.read_csv("sint_diario.csv", parse_dates=["fecha"])
con.sql("SELECT count(*) AS sesiones FROM precios WHERE cierre > 120")
OUT [10]Resultados como DataFrame
DataFrame (1303, 4) datetime64[us]
DataFrame (1303, 4) Date
┌──────────┐
│ sesiones │
│  int64   │
├──────────┤
│      523 │
└──────────┘

.df() devuelve pandas y .pl() devuelve Polars (también hay .arrow(), que devuelve datos de Arrow, y .fetchall(), que devuelve tuplas de Python). La última consulta va en sentido contrario: precios no es una tabla de la base de datos, sino un DataFrame de pandas en una variable de Python, y DuckDB lo encuentra por su nombre y lo consulta en el sitio, sin copiarlo. Las 523 sesiones por encima de 120 son las de la lección 0.5.2.

TareaHerramienta natural
Consultas con uniones y agregaciones sobre muchos ficheros ParquetDuckDB o Polars lazy
Lógica compartida con una base de datos SQL o con otro equipoDuckDB (SQL estándar)
Series temporales con índice, resample, modelos estadísticospandas
Transformaciones encadenadas en PythonPolars

Las tres herramientas se combinan sin fricción porque comparten Apache Arrow como formato en memoria. Un flujo habitual: DuckDB filtra y agrega los ficheros grandes, y pandas recibe la tabla ya pequeña para el análisis.

Ejercicios

  1. Con una consulta sobre sint_diario.parquet, calcula la rentabilidad media diaria y el número de sesiones de cada día de la semana (usa dayname(fecha) y LAG). Compara con la tabla de la lección 0.5.3.
Solución
IN [11]python
con.sql("""
    SELECT dayname(fecha) AS dia,
           count(r) AS sesiones,
           round(avg(r) * 100, 3) AS media_pct
    FROM sint
    GROUP BY dia, isodow(fecha)
    ORDER BY isodow(fecha)
""")
OUT [11]Rentabilidad por día de la semana en SQL
┌───────────┬──────────┬───────────┐
│    dia    │ sesiones │ media_pct │
│  varchar  │  int64   │  double   │
├───────────┼──────────┼───────────┤
│ Monday    │      260 │     0.045 │
│ Tuesday   │      261 │    -0.052 │
│ Wednesday │      261 │     0.019 │
│ Thursday  │      260 │     0.051 │
│ Friday    │      260 │     0.108 │
└───────────┴──────────┴───────────┘

Las cifras coinciden con las de pandas. count(r) no cuenta los NULL, así que el lunes inicial (sin rentabilidad) no suma; isodow (1 = lunes) ordena los días de la semana en lugar de alfabéticamente. Las mismas advertencias valen: las diferencias son del orden del error típico.

  1. Une cada operación de SINT con el cierre del día de salida y comprueba que el precio de salida es ese cierre en todas. Usa LEFT JOIN y cuenta las operaciones sin pareja.
Solución
IN [12]python
con.sql("""
    SELECT count(*) AS operaciones,
           count(p.cierre) AS con_precio,
           sum(CASE WHEN o.precio_salida = p.cierre
                    THEN 1 ELSE 0 END) AS iguales
    FROM 'operaciones_sint.csv' AS o
    LEFT JOIN sint AS p ON p.fecha = o.fecha_salida
    WHERE o.simbolo = 'SINT'
""")
OUT [12]Precio de salida frente al cierre
┌─────────────┬────────────┬─────────┐
│ operaciones │ con_precio │ iguales │
│    int64    │   int64    │ int128  │
├─────────────┼────────────┼─────────┤
│          30 │         30 │      30 │
└─────────────┴────────────┴─────────┘

Con LEFT JOIN, una operación sin precio el día de salida aparecería con p.cierre nulo y count(p.cierre) sería menor que count(*). No falta ninguna, y las 30 salidas se hicieron al cierre, como dice el README de los datos.

  1. Escribe una señal de cruce sin anticipación: 1 si el cierre está por encima de su media de 50 sesiones (con la ventana completa) y 0 si no. ¿En qué porcentaje de las sesiones con ventana completa estuvo SINT por encima?
Solución
IN [13]python
con.sql("""
    WITH m AS (
        SELECT fecha, cierre,
               AVG(cierre) OVER w AS media,
               COUNT(cierre) OVER w AS n
        FROM sint
        WINDOW w AS (ORDER BY fecha
                     ROWS BETWEEN 49 PRECEDING AND CURRENT ROW)
    )
    SELECT count(*) AS sesiones,
           round(avg(CASE WHEN cierre > media THEN 1 ELSE 0 END), 3)
               AS por_encima
    FROM m
    WHERE n = 50
""")
OUT [13]Señal de media móvil en SQL
┌──────────┬────────────┐
│ sesiones │ por_encima │
│  int64   │   double   │
├──────────┼────────────┤
│     1254 │      0.649 │
└──────────┴────────────┘

WITH define una consulta auxiliar (una CTE) que la principal usa como tabla. Las ventanas se calculan dentro de la CTE y el filtro n = 50 se aplica fuera, después: en el mismo nivel, WHERE actuaría antes que la ventana. SINT estuvo por encima de su media en el 64,9 % de las 1.254 sesiones con ventana completa. La señal de cada día usa el cierre de ese día, así que solo puede operarse al cierre o en la sesión siguiente.

Respuesta plegada en cada pregunta

Autoevaluación

  1. ¿En qué orden evalúa SQL estas cláusulas de una consulta?

    • ASELECT, FROM, WHERE, GROUP BY, ORDER BY
    • BFROM, WHERE, GROUP BY, SELECT, ORDER BY
    • CWHERE, FROM, SELECT, GROUP BY, ORDER BY
    • DEn el orden en que se escriben
    Ver respuesta

    Respuesta B. FROM, WHERE, GROUP BY, SELECT, ORDER BY

    Primero se leen y combinan las tablas (FROM, JOIN), después se filtran filas (WHERE), se agrupan (GROUP BY), se calculan las columnas del SELECT (incluidas las ventanas) y al final se ordena. Por eso WHERE no puede usar un alias del SELECT ni una función de ventana.

  2. AVG(cierre) OVER () en la fila del 1 de junio de 2021, ¿qué media calcula?

    • ALa de las sesiones hasta el 1 de junio
    • BLa de toda la tabla, incluidas las sesiones posteriores: anticipación
    • CLa de las 50 sesiones anteriores
    • DNinguna: es un error de sintaxis
    Ver respuesta

    Respuesta B. La de toda la tabla, incluidas las sesiones posteriores: anticipación

    Sin ORDER BY ni marco, la ventana es la partición entera. Para usar solo el pasado hay que ordenar por fecha y limitar el marco: ROWS BETWEEN 49 PRECEDING AND CURRENT ROW.

  3. Una media móvil de 50 sesiones con ROWS BETWEEN 49 PRECEDING AND CURRENT ROW, ¿qué devuelve en la décima fila de la tabla?

    • ANULL, porque no hay 50 filas
    • BLa media de las diez filas disponibles
    • CUn error
    • DLa media de las 50 filas siguientes
    Ver respuesta

    Respuesta B. La media de las diez filas disponibles

    SQL calcula con las filas que hay en el marco. A diferencia de rolling(50) de pandas, no devuelve nulo con ventanas incompletas: hay que comprobarlo con COUNT(*) OVER del mismo marco.

  4. ¿Qué hace con.sql("SELECT ... FROM precios") si precios es un DataFrame de pandas definido en Python?

    • AFalla, porque precios no es una tabla de la base de datos
    • BConsulta el DataFrame directamente, sin copiarlo a una tabla
    • CBusca un fichero llamado precios
    • DCrea una base de datos en disco
    Ver respuesta

    Respuesta B. Consulta el DataFrame directamente, sin copiarlo a una tabla

    DuckDB busca en las variables de Python un DataFrame de pandas, de Polars o una tabla de Arrow con ese nombre y lo lee en el sitio (*replacement scan*).

Ideas clave

  • DuckDB es una base de datos analítica que se ejecuta dentro de Python: consulta Parquet y CSV por su ruta (FROM 'sint_diario.parquet') sin importarlos.
  • SQL se evalúa en este orden: FROM/JOIN, WHERE, GROUP BY, HAVING, SELECT (con las ventanas), ORDER BY, LIMIT.
  • Las funciones de ventana calculan sobre filas vecinas sin agruparlas: LAG para rentabilidades, AVG OVER para medias móviles, MAX OVER para el máximo acumulado.
  • Contra la anticipación, cada ventana lleva ORDER BY fecha y un marco explícito ROWS BETWEEN n PRECEDING AND CURRENT ROW. OVER () usa toda la tabla, futuro incluido.
  • .df() y .pl() devuelven el resultado como pandas o Polars, y DuckDB puede consultar con SQL un DataFrame que ya está en memoria.

Recursos

Datos