---
title: "0.5.5 DuckDB y SQL esencial sobre Parquet"
description: "SQL con DuckDB sobre los Parquet y CSV de SINT: SELECT, WHERE, GROUP BY, JOIN y funciones de ventana sin anticipación; resultados a pandas y Polars."
url: "https://pythonparatrading.com/curso-python-trading/nivel-0/duckdb-y-sql-sobre-parquet/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-0/duckdb-y-sql-sobre-parquet.png"
---

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

- Curso: [Nivel 0: Python desde cero y mentalidad de datos](https://pythonparatrading.com/curso-python-trading/nivel-0/)
- Módulo: 0.5 · Computación numérica y datos
- Duración: 50 min
- Requisitos: [0.5.4 · Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas](https://pythonparatrading.com/curso-python-trading/nivel-0/polars-lazy-y-expresiones/)

## Al terminar esta lección

- Consultar un fichero Parquet o CSV con `SELECT`, `WHERE`, `ORDER BY` y `LIMIT` desde Python con DuckDB.
- Resumir por año con `GROUP BY` y combinar el libro de operaciones con los precios mediante `JOIN`.
- Calcular rentabilidades, medias móviles y drawdown con funciones de ventana (`LAG`, `AVG OVER`, `MAX OVER`) con un marco `ROWS BETWEEN … PRECEDING AND CURRENT ROW` explícito.
- Pasar el resultado de una consulta a pandas o Polars y consultar con SQL un `DataFrame` que ya está en memoria.

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

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

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

```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
""")
```
```output
┌────────────┬────────┬─────────┐
│    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:

```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
""")
```
```output
┌───────┬──────────┬────────┬────────┬────────┬─────────────┐
│  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.

```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
""")
```
```output
┌───────┬───────────────┬─────────┬────────────────┬────────┬─────────┐
│  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.

| `JOIN` | Filas que conserva | Equivale en pandas a |
|---|---|---|
| `JOIN` (o `INNER JOIN`) | Solo las que tienen pareja | `how="inner"` |
| `LEFT JOIN` | Todas las de la izquierda | `how="left"` |
| `FULL JOIN` | Todas las de los dos lados | `how="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`:

```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
""")
```
```output
┌─────────┬─────────────┬──────────┐
│ 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:

```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
""")
```
```output
┌────────────┬────────┬───────────┐
│   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:

```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
""")
```
```output
┌───────┬────────────┬────────┬──────────┬───────┐
│ 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.

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

Sin un marco explícito, una ventana con `ORDER BY` usa por defecto `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT
ROW`: todo el pasado, más las filas **empatadas** con la actual en la clave de orden. Con una fecha por fila no hay
empates; con barras intradía ordenadas solo por día, la ventana incluiría el resto del día, que es futuro. Y sin
`ORDER BY`, `OVER ()` es la tabla entera. Escribe siempre el `ORDER BY` y el marco `ROWS`.

</aside>

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

```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'
""")
```
```output
┌────────────┬────────┬────────────┬─────────────────┐
│   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:

```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
""")
```
```output
┌─────────┬────────────┬─────────┐
│ 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:

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

| Tarea | Herramienta natural |
|---|---|
| Consultas con uniones y agregaciones sobre muchos ficheros Parquet | DuckDB o Polars lazy |
| Lógica compartida con una base de datos SQL o con otro equipo | DuckDB (SQL estándar) |
| Series temporales con índice, `resample`, modelos estadísticos | pandas |
| Transformaciones encadenadas en Python | Polars |

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

```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)
""")
```
```output
┌───────────┬──────────┬───────────┐
│    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.

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

```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'
""")
```
```output
┌─────────────┬────────────┬─────────┐
│ 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.

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

```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
""")
```
```output
┌──────────┬────────────┐
│ 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.

## Autoevaluación

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

   - A. `SELECT`, `FROM`, `WHERE`, `GROUP BY`, `ORDER BY`
   - B. `FROM`, `WHERE`, `GROUP BY`, `SELECT`, `ORDER BY`
   - C. `WHERE`, `FROM`, `SELECT`, `GROUP BY`, `ORDER BY`
   - D. En el orden en que se escriben

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

   - A. La de las sesiones hasta el 1 de junio
   - B. La de toda la tabla, incluidas las sesiones posteriores: anticipación
   - C. La de las 50 sesiones anteriores
   - D. Ninguna: es un error de sintaxis

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

   - A. `NULL`, porque no hay 50 filas
   - B. La media de las diez filas disponibles
   - C. Un error
   - D. La media de las 50 filas siguientes

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

   - A. Falla, porque `precios` no es una tabla de la base de datos
   - B. Consulta el `DataFrame` directamente, sin copiarlo a una tabla
   - C. Busca un fichero llamado `precios`
   - D. Crea una base de datos en disco

   *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

### Research

- [Big Data para trading con ArcticDB](https://pythonparatrading.com/big-data-para-trading/) · Research-0019 · 16 min

### Documentación

- [DuckDB: API de Python](https://duckdb.org/docs/current/clients/python/overview.html) · duckdb.org
- [DuckDB: leer y escribir Parquet](https://duckdb.org/docs/current/data/parquet/overview.html) · duckdb.org
- [DuckDB: funciones de ventana](https://duckdb.org/docs/current/sql/functions/window_functions.html) · duckdb.org
- [DuckDB: SQL sobre DataFrames de pandas](https://duckdb.org/docs/current/guides/python/sql_on_pandas.html) · duckdb.org

### Datos

- [Datos del nivel 0: barras diarias de SINT (Parquet)](https://pythonparatrading.com/curso/datos/nivel-0/sint_diario.parquet) · Descarga · sint_diario.parquet

Lección anterior: [0.5.4 · Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas](https://pythonparatrading.com/curso-python-trading/nivel-0/polars-lazy-y-expresiones/)

Lección siguiente: [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/)
