Lección 0.5.2 · 45 min
pandas 3.0: Series, DataFrame, índices temporales, Copy-on-Write, tipo str y pd.col()
pandas es la tabla de trabajo del research en Python: precios con fechas, operaciones con columnas de texto, resultados alineados por índice. La versión 3.0 cambia tres cosas que rompen código antiguo —Copy-on-Write, el tipo str y la resolución de las fechas— y añade pd.col(). Esta lección las enseña con los datos del curso.
- Módulo
- 0.5 · Computación numérica y datos
- 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
Series y DataFrame
La lección 0.5.1 trabajaba con arrays de NumPy: números sin nombre y sin fecha. Para research hace falta más: saber a qué sesión corresponde cada cierre, qué columna es el volumen y qué operación es de qué activo. pandas añade eso sobre NumPy con dos estructuras:
Series: un array de una dimensión con un índice (las etiquetas de las filas) y un nombre. Los cierres de SINT son unaSeriesindexada por fecha.DataFrame: varias columnas que comparten el mismo índice, cada una con su tipo. Las barras diarias de SINT (apertura, máximo, mínimo, cierre y volumen) son unDataFrame.
Las versiones del curso son las de curso-code/uv.lock: pandas 3.0.6, la serie 3.0 que se publicó en enero de 2026.
Todo lo que sigue funciona con ella, y las diferencias con pandas 2 se señalan donde aparecen.
import numpy as np
import pandas as pd
precios = pd.read_csv("sint_diario.csv", parse_dates=["fecha"],
index_col="fecha")
print(pd.__version__, type(precios).__name__, precios.shape)
print(precios.dtypes)
precios.head(3)3.0.6 DataFrame (1303, 5)
apertura float64
maximo float64
minimo float64
cierre float64
volumen int64
dtype: object
apertura maximo minimo cierre volumen
fecha
2021-01-04 100.02 100.64 99.07 99.14 2322609
2021-01-05 99.32 99.79 98.63 99.25 1517954
2021-01-06 99.10 99.24 97.37 97.38 3245658parse_dates convierte la columna de texto en fechas e index_col la usa como índice. Sin esas dos opciones, las
fechas serían texto y el índice, los números de fila 0, 1, 2…: todo lo que viene después dejaría de funcionar. Cada
columna tiene su tipo: los precios son float64 y el volumen, int64, porque es un número entero de títulos.
Una columna de un DataFrame es una Series. Las operaciones vectorizadas de NumPy siguen funcionando, y pandas
añade métodos con nombre para las más comunes en finanzas, como pct_change (la rentabilidad simple de la lección
0.5.1):
cierre = precios["cierre"]
print(type(cierre).__name__, cierre.name, cierre.dtype)
r = cierre.pct_change()
print(r.head(3))
print(f"Media diaria: {r.mean():.4%} · sesiones: {r.count()}")Series cierre float64
fecha
2021-01-04 NaN
2021-01-05 0.001110
2021-01-06 -0.018841
Name: cierre, dtype: float64
Media diaria: 0.0342% · sesiones: 1302La primera rentabilidad es NaN: el primer día no tiene cierre anterior. pandas no la elimina y, a diferencia de
np.mean, mean() y count() ignoran los NaN por defecto, por eso salen 1.302 rentabilidades de 1.303 cierres.
Es cómodo y peligroso a la vez: un hueco en los datos no rompe la media, así que tampoco avisa.
Alineación por índice
La diferencia de fondo entre pandas y NumPy es la alineación: cuando dos Series operan juntas, pandas empareja
los valores por su etiqueta, no por su posición. Con dos activos que cotizan en fechas distintas —un mercado cierra
por festivo, un proveedor no tiene un día—, el resultado tiene la unión de las fechas y NaN donde falta uno de los
dos. Un ejemplo con SINT y RVAR, el activo de renta variable de cartera_sint.csv, en dos tramos que solo se solapan
dos días:
cartera = pd.read_csv("cartera_sint.csv", parse_dates=["fecha"],
index_col="fecha")
sint = cierre["2024-01-02":"2024-01-05"]
rvar = cartera["RVAR"]["2024-01-04":"2024-01-09"]
(sint / rvar).round(4)fecha
2024-01-02 NaN
2024-01-03 NaN
2024-01-04 0.7574
2024-01-05 0.7577
2024-01-08 NaN
2024-01-09 NaN
dtype: float64Con arrays de NumPy, dividir dos vectores de cuatro elementos habría emparejado el 2 de enero de SINT con el 4 de
enero de RVAR sin dar ningún error: un ratio entre precios de días distintos. pandas lo impide: solo divide lo que
corresponde a la misma fecha y marca el resto como ausente. Qué hacer con esos ausentes (descartarlos, arrastrar el
último precio) es una decisión que la lección 0.5.3 trata con merge, join y reindex.
Índices temporales
Un índice de fechas, DatetimeIndex, permite seleccionar por fechas escritas como texto. La selección parcial
—solo el año o el año y el mes— devuelve todas las sesiones de ese periodo, y los rangos con loc incluyen los dos
extremos (al contrario que los cortes por posición de Python):
print(precios.index.dtype, precios.index.min().date(),
precios.index.max().date())
print(len(precios.loc["2024"]), "sesiones en 2024")
print(precios.loc["2022-03", "cierre"].iloc[[0, -1]])
precios.loc["2022-06-01":"2022-06-07", ["cierre", "volumen"]]datetime64[us] 2021-01-04 2025-12-31
262 sesiones en 2024
fecha
2022-03-01 117.20
2022-03-31 111.28
Name: cierre, dtype: float64
cierre volumen
fecha
2022-06-01 108.39 1463683
2022-06-02 105.80 1832370
2022-06-03 104.44 1709652
2022-06-06 102.97 2301634
2022-06-07 102.23 1637924El índice es datetime64[us]: fechas con resolución de microsegundo. Es un cambio de pandas 3.0: al leer fechas
de texto, la resolución por defecto pasa de nanosegundo a microsegundo (la de nanosegundo solo se usa si el texto lo
necesita). Para seleccionar o comparar fechas no cambia nada; donde muerde es al convertir fechas en números, algo
habitual al enviar marcas de tiempo a la API de un bróker, que suele esperarlas en milisegundos desde 1970:
tres = precios.index[:3]
print(tres.astype("int64").tolist())
print(tres.as_unit("ns").astype("int64").tolist())
print(tres.as_unit("ms").astype("int64").tolist())[1609718400000000, 1609804800000000, 1609891200000000]
[1609718400000000000, 1609804800000000000, 1609891200000000000]
[1609718400000, 1609804800000, 1609891200000]El mismo 4 de enero de 2021 se convierte en un número mil veces menor que en pandas 2. Un código antiguo que dividía
entre 10**6 para pasar de nanosegundos a milisegundos ahora produce fechas de 1970 sin dar ningún error. La regla:
antes de convertir a entero, fija la unidad con as_unit.
El índice sabe qué día de la semana es cada sesión, cuántas hay por año y si hay huecos. Un vistazo rápido al calendario de los datos antes de calcular nada evita sorpresas:
print(precios.index.year.value_counts().sort_index().to_dict())
print((precios.index.dayofweek >= 5).sum(), "sesiones en fin de semana")
precios.loc["2025-12-23":"2025-12-29", "cierre"]{2021: 260, 2022: 260, 2023: 260, 2024: 262, 2025: 261}
0 sesiones en fin de semana
fecha
2025-12-23 139.43
2025-12-24 139.36
2025-12-25 139.32
2025-12-26 139.23
2025-12-29 139.56
Name: cierre, dtype: float64No hay fines de semana, pero el 25 de diciembre de 2025 tiene cierre. No es un error de lectura: SINT es un activo ficticio que cotiza todos los días hábiles, sin festivos, como dice el README de los datos. Un mercado real cierra en Navidad, y los calendarios de cada bolsa (lección 0.4.4) son la referencia para saber qué sesiones deberían existir. Comprobar el calendario es el primer paso de cualquier limpieza de datos: un festivo con precio, o una sesión que falta, desplaza todas las rentabilidades de alrededor.
Copy-on-Write: cada objeto es suyo
En pandas 2, precios["cierre"] podía devolver una vista: un objeto que compartía memoria con el DataFrame, de
modo que cambiarlo cambiaba también el original. Otras operaciones devolvían una copia. Cuál de las dos dependía de
detalles internos, y el aviso SettingWithCopyWarning intentaba, sin mucho éxito, avisar de los casos dudosos.
pandas 3.0 elimina la ambigüedad con Copy-on-Write (CoW): el resultado de cualquier indexado o método se comporta como una copia. Por dentro no copia nada mientras nadie escriba (por eso es rápido); la copia real se hace en el momento de la primera escritura:
Fuente Mermaid
flowchart LR A[precios] --> B[cierre] B -->|lectura| C[Memoria común] A -->|lectura| C B -->|escritura| D[Copia de cierre]
En la práctica, solo hay una forma de cambiar un objeto: escribir sobre ese objeto directamente.
df = precios.copy()
col = df["cierre"]
col.iloc[0] = 0.0 # se escribe en la Series extraída
print(col.iloc[0], df["cierre"].iloc[0])0.0 99.14col cambia y df no. En pandas 2 sin CoW, esta misma celda habría puesto un cero en el primer cierre de df: un
precio de cero que nadie escribió a propósito y que aparece después como una caída del 100 % en un backtest.
La otra cara es la asignación encadenada: dos indexados seguidos y una asignación al final. Es el error más frecuente al construir una columna de señal, y en pandas 3.0 no funciona nunca:
import warnings
df["senal"] = 0
with warnings.catch_warnings(record=True) as avisos:
warnings.simplefilter("always")
df["senal"][df["cierre"] > 120] = 1 # encadenada
print([a.category.__name__ for a in avisos])
print("Con asignación encadenada:", df["senal"].sum())
df.loc[df["cierre"] > 120, "senal"] = 1 # un solo paso
print("Con .loc:", df["senal"].sum())['ChainedAssignmentError']
Con asignación encadenada: 0
Con .loc: 523df["senal"] devuelve un objeto intermedio que se comporta como una copia; la asignación se hace sobre él y se pierde.
pandas lo detecta y emite el aviso ChainedAssignmentError (en la celda se recoge con warnings para mostrar solo su
nombre; en un notebook sale el mensaje completo). La versión con .loc indica filas y columna en una sola operación
sobre df, y es la única que cambia el DataFrame: 523 sesiones con cierre por encima de 120.
| Patrón | pandas 2 | pandas 3.0 |
|---|---|---|
df["c"][mascara] = v | Cambiaba df (a veces con aviso) | No cambia nada; ChainedAssignmentError |
s = df["c"]; s.iloc[0] = v | Podía cambiar df | Solo cambia s |
df.loc[mascara, "c"] = v | Cambia df | Cambia df |
sub = df[mascara].copy() | .copy() para callar el aviso | .copy() innecesario |
El tipo str y PyArrow
Hasta pandas 2, una columna de texto era de tipo object: un array de punteros a cadenas de Python, cada una en su
propio sitio de la memoria, igual que una lista. pandas 3.0 infiere por defecto un tipo de texto propio, str,
guardado con PyArrow cuando está instalado (en el proyecto del curso lo está, porque lo usa Parquet). El libro de
operaciones ficticias tiene dos columnas de texto:
ops = pd.read_csv("operaciones_sint.csv",
parse_dates=["fecha_entrada", "fecha_salida"])
print(ops.dtypes)
print(ops["lado"].dtype.storage, ops["lado"].dtype.na_value)
ops.iloc[:3, :6]id int64
fecha_entrada datetime64[us]
fecha_salida datetime64[us]
simbolo str
lado str
cantidad int64
precio_entrada float64
precio_salida float64
comision float64
dtype: object
pyarrow nan
id fecha_entrada fecha_salida simbolo lado cantidad
0 1 2021-03-04 2021-04-26 ORO corto 200
1 2 2021-09-02 2021-10-12 SINT largo 180
2 3 2021-10-27 2021-12-08 SINT corto 10simbolo y lado son str, con almacenamiento pyarrow y NaN como valor ausente (el mismo que en las columnas
numéricas). Las fechas de entrada y salida, como el índice de precios, son datetime64[us].
El cambio es intencionado y rompe un patrón muy extendido: buscar las columnas de texto comparando su tipo con
object.
lado = ops["lado"]
print(lado.dtype == object)
print(lado.dtype == "str")
print(pd.api.types.is_string_dtype(lado))
print(ops.select_dtypes(include="str").columns.tolist())False
True
True
['simbolo', 'lado']dtype == object devuelve False sin dar error: el código que limpiaba las columnas de texto así simplemente deja de
encontrarlas. Las dos formas robustas son pd.api.types.is_string_dtype, que acepta el tipo antiguo y el nuevo, y
select_dtypes(include="str"). select_dtypes(include="object") todavía incluye las de texto por compatibilidad, pero
con un aviso de que dejará de hacerlo.
El tipo str es además más estricto: solo admite cadenas o ausentes. Un número en la columna lado era posible con
object y era un error latente; ahora se rechaza al escribirlo:
ops.loc[0, "lado"] = 1TypeError: Invalid value '1' for dtype 'str'. Value should be a string or missing value, got 'int' instead.Las operaciones de texto siguen en el accesor .str (ops["simbolo"].str.upper(), .str.startswith("l")) y las
comparaciones (ops["lado"] == "largo") funcionan igual. Con PyArrow, además, la columna ocupa menos memoria y esas
operaciones suelen ser más rápidas que con object: en ops, lado ocupa 520 bytes como str y 2.160 como
object (memory_usage(deep=True)). En tablas de millones de órdenes, la diferencia se nota.
Expresiones con pd.col()
Para crear columnas derivadas, assign aceptaba hasta ahora valores o funciones que reciben el propio DataFrame
(lambda d: d["cierre"] / ...). pandas 3.0 añade pd.col("nombre"): una referencia a una columna que se puede
combinar con operadores y con los métodos de Series, y que pandas evalúa sobre el DataFrame en el que se usa. Es la
misma idea que las expresiones de Polars (lección 0.5.4):
barras = precios.assign(
r=pd.col("cierre").pct_change(),
rango=(pd.col("maximo") - pd.col("minimo")) / pd.col("cierre"),
)
print(pd.col("cierre").pct_change())
barras[["cierre", "r", "rango"]].head(3).round(4)col('cierre').pct_change()
cierre r rango
fecha
2021-01-04 99.14 NaN 0.0158
2021-01-05 99.25 0.0011 0.0117
2021-01-06 97.38 -0.0188 0.0192Impreso, pd.col("cierre").pct_change() no es un número ni una columna: es una expresión pendiente de evaluar.
Por eso se puede guardar en una variable y reutilizar. En el libro de operaciones, el signo de cada operación (1 para
largo, −1 para corto) sirve para calcular su resultado neto de comisiones:
signo = pd.col("lado").map({"largo": 1, "corto": -1})
ops = ops.assign(
pnl=signo * (pd.col("precio_salida") - pd.col("precio_entrada"))
* pd.col("cantidad") - pd.col("comision"),
)
print(f"Resultado total: {ops['pnl'].sum():,.2f}")
ops.loc[pd.col("pnl") < -1000, ["id", "simbolo", "lado", "pnl"]]Resultado total: 1,149.19
id simbolo lado pnl
0 1 ORO corto -3123.56
7 8 SINT largo -1288.04
14 15 SINT largo -1058.60pd.col funciona en cualquier sitio que acepte una función del DataFrame: assign, loc y la selección con
corchetes. En loc, la expresión pd.col("pnl") < -1000 devuelve la máscara de las operaciones con una pérdida mayor
de 1.000 en la moneda del activo: tres de las cuarenta, una en ORO y dos en SINT. El libro en conjunto gana 1.149,19
netos de comisiones (Python formatea los miles con coma: 1,149.19). La ventaja frente a df["pnl"] < -1000 aparece al encadenar métodos: la expresión
se refiere al DataFrame del paso en que se evalúa, no a una variable que puede haber quedado desfasada.
Ejercicios
-
Este código intenta marcar con un 1 en
alertalas sesiones de SINT con un volumen mayor que el doble de la mediana, pero la columna se queda a cero. Explica por qué y corrígelo.IN [14]pythonv = precios.copy() v["alerta"] = 0 v[v["volumen"] > 2 * v["volumen"].median()]["alerta"] = 1
Solución
v = precios.copy()
v["alerta"] = 0
v.loc[v["volumen"] > 2 * v["volumen"].median(), "alerta"] = 1
print(v["alerta"].sum(), "sesiones con volumen anómalo")51 sesiones con volumen anómalov[mascara] devuelve un DataFrame nuevo que se comporta como una copia; ["alerta"] = 1 lo modifica a él y se
pierde. Con .loc[mascara, "alerta"], filas y columna se indican en una sola operación sobre v, y se marcan 51
sesiones.
- Con
pd.col(), añade apreciosuna columnahuecocon el gap de apertura (apertura de hoy entre cierre de ayer, menos uno) y selecciona conloclas sesiones de 2022 en que abrió más de un 1 % por debajo del cierre anterior.
Solución
gaps = precios.assign(
hueco=pd.col("apertura") / pd.col("cierre").shift(1) - 1,
)
bajistas = gaps.loc["2022"].loc[pd.col("hueco") < -0.01]
print(len(bajistas), "huecos bajistas de más del 1 %")
bajistas[["apertura", "cierre", "hueco"]].round(4)3 huecos bajistas de más del 1 %
apertura cierre hueco
fecha
2022-08-22 109.40 108.59 -0.0127
2022-08-29 111.41 115.26 -0.0108
2022-09-02 113.75 117.11 -0.0109shift(1) desplaza los cierres una sesión hacia delante, así que cada fila compara su apertura con el cierre anterior:
información disponible en el momento de la apertura, sin anticipación. Salen tres sesiones, todas entre finales de
agosto y principios de septiembre. El primer loc selecciona el año y el segundo
filtra con la expresión, que se evalúa sobre el DataFrame ya recortado.
- Escribe una función
columnas_texto(df)que devuelva los nombres de las columnas de texto de unDataFramey que funcione igual con columnasstry con columnasobjectde pandas 2. Pruébala conopsy conops.astype({"lado": object}).
Solución
def columnas_texto(df: pd.DataFrame) -> list[str]:
return [c for c in df.columns
if pd.api.types.is_string_dtype(df[c])]
print(columnas_texto(ops))
print(columnas_texto(ops.astype({"lado": object})))['simbolo', 'lado']
['simbolo', 'lado']is_string_dtype reconoce el tipo str y también una columna object cuyos valores son cadenas, así que la función
da el mismo resultado con los dos tipos. Comparar con object solo encontraría la segunda.
Respuesta plegada en cada pregunta
Autoevaluación
En pandas 3.0,
df[df["cierre"] > 120]["senal"] = 1. ¿Qué le pasa adf?- ASe pone 1 en
senalen los días con cierre mayor que 120 - BNada: la asignación se hace sobre un objeto intermedio que se comporta como una copia, y pandas avisa con
ChainedAssignmentError - CLanza una excepción y el programa se para
- DSe pone 1 en todas las filas
Ver respuesta
Respuesta B. Nada: la asignación se hace sobre un objeto intermedio que se comporta como una copia, y pandas avisa con
ChainedAssignmentErrorCon Copy-on-Write, el resultado de cualquier indexado se comporta como una copia. La asignación encadenada nunca llega al original; la forma correcta es
df.loc[df["cierre"] > 120, "senal"] = 1.- ASe pone 1 en
Una columna
ladocon «largo» y «corto» se lee con pandas 3.0 y PyArrow instalado. ¿Qué devuelvedf["lado"].dtype == object?- A
True, como en pandas 2 - B
False: la columna es del tipostr - CUn error de tipo
- DDepende del contenido de la primera fila
Ver respuesta
Respuesta B.
False: la columna es del tipostrpandas 3.0 infiere el tipo
strpara el texto. Las comprobaciones deobjectdejan de encontrar esas columnas; lo robusto espd.api.types.is_string_dtype.- A
Se suman los cierres de dos activos con calendarios distintos:
a + b. ¿Qué pasa con las fechas que solo están en uno de los dos?- ASe suman por posición, sin mirar las fechas
- BSe descartan en silencio
- CAparecen en el resultado con
NaN - DSe rellenan con el último precio conocido
Ver respuesta
Respuesta C. Aparecen en el resultado con
NaNpandas alinea por índice: el resultado tiene la unión de las fechas, y donde falta uno de los dos operandos el valor es
NaN. Rellenar o descartar es una decisión explícita.En pandas 3.0,
pd.read_csv(..., parse_dates=["fecha"])crea fechasdatetime64[us]. ¿Qué devuelveindex.astype("int64")?- ANanosegundos desde 1970, como en pandas 2
- BMicrosegundos desde 1970: números 1.000 veces menores que en pandas 2
- CSegundos desde 1970
- DDías desde 1970
Ver respuesta
Respuesta B. Microsegundos desde 1970: números 1.000 veces menores que en pandas 2
La resolución por defecto al leer texto pasa a ser el microsegundo. Para obtener otra unidad hay que pedirla con
as_unit("ns")oas_unit("ms")antes de convertir a entero.
Ideas clave
- Una
Serieses una columna con índice; unDataFrame, varias columnas que comparten índice. Toda operación entre ellas se alinea por índice, no por posición. - Con un
DatetimeIndex,loc["2022-03"]selecciona un mes yloc["2022-06-01":"2022-06-07"]un rango con los dos extremos incluidos. - Copy-on-Write: cualquier indexado se comporta como una copia. Para cambiar un
DataFrame, se asigna sobre él en un solo paso con.loc; la asignación encadenada no hace nada. - El texto es del tipo
str(PyArrow por debajo): las comprobacionesdtype == objectfallan en silencio y la columna solo admite cadenas oNaN. pd.col("cierre")construye una expresión queassignylocevalúan sobre el propioDataFrame, sinlambda.