Lección 7.1.2 · 75 min
Rendimiento: medir, perfilar y compilar solo lo que hace falta
Un backtest de ruptura con stop dinámico tarda casi veinte segundos en recorrer cien mil barras de un minuto. Esta lección lo mide, encuentra con tres perfiladores dónde se va el tiempo (no es donde parece), lo reescribe sin pandas dentro del bucle y lo compila con Numba y con Cython hasta que el bucle deja de importar. Después presenta el perfilador de muestreo de Python 3.15 y la vía de Rust con PyO3.
- Módulo
- 7.1 · Ingeniería de software para trading
- Duración
- 75 min
- Requisitos
- 0.4.6 · Concurrencia: threading, multiprocessing, asyncio y free-threading, 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(), 2.4.2 · Backtest por eventos desde cero: bucle, cartera, órdenes y fills, 7.1.1 · Estructura de proyecto, configuración y calidad: del notebook al repositorio
Lecciones del nivel 7
7.1 · Ingeniería de software para trading
7.2 · Infraestructura y despliegue
7.3 · MLOps para modelos de trading
- 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
- 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
- 7.3.3 Monitorización del modelo en vivo y desactivación automática
- 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
7.4 · Regulación, ética y fiscalidad
- 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
- 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
- 7.4.3 PRIIPs, KID y ETFs estadounidenses
- 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
- 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
- 7.4.6 Fiscalidad del trading en España
- 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
7.5 · Carrera profesional quant
Medir antes de optimizar
El código lento de un sistema de trading casi nunca es la aritmética de la estrategia. Suele ser cómo se recorren los datos: una fila de pandas cada vez, una conversión en cada vuelta, una lectura de disco dentro de un bucle. Adivinar dónde está el problema es la forma más común de perder una tarde optimizando lo que no importa. El método es siempre el mismo:
Fuente Mermaid
flowchart TB M[Medir] --> P[Perfilar] P --> A[Algoritmo] A --> C[Compilar] C --> M2[Medir otra vez]
Primero se mide el programa entero con datos realistas; después se perfila para saber qué parte se lleva el tiempo; se cambia el algoritmo o la estructura de datos (casi siempre lo que más rinde) y, solo si sigue sin bastar, se compila la parte caliente. Y se vuelve a medir, porque el cuello de botella se mueve. Cuánto se puede ganar lo acota la ley de Amdahl: si una parte ocupa la fracción del tiempo y se acelera veces, el programa entero se acelera
Con , acelerar esa parte 100 veces da ; ni siquiera con infinito se pasa de 10. La figura del final de la lección dibuja la curva para varias .
El caso de la lección es una estrategia de ruptura intradía sobre las 96.330 barras de un minuto del futuro sintético
del nivel 5 (hf5_barras_1min.parquet): compra cuando el cierre supera el máximo de las 30 barras anteriores; sale
cuando el cierre cae más de 4 ATR por debajo del máximo alcanzado desde la entrada (un stop dinámico) o cuando empieza
una sesión nueva. El ATR es aquí una media exponencial del rango de cada barra (máximo menos mínimo), de 14 barras. La
salida depende de la trayectoria (el máximo desde la entrada, si se está dentro o no), así que no se puede escribir
con operaciones de columnas: hace falta un bucle, como en la lección 2.4.2. Así lo escribiría alguien que viene de un
notebook:
import time
import numpy as np
import pandas as pd
df = pd.read_parquet("hf5_barras_1min.parquet")
N, K, ALFA = 30, 4.0, 2 / 15 # ruptura de 30, stop de 4 ATR
def backtest_ingenuo(df, n=N, k=K, alfa=ALFA):
"""Posición (0 o 1) en cada barra."""
pos, pico, atr = 0, 0.0, None
salida = []
for i in range(len(df)):
fila = df.iloc[i]
rango = fila["maximo"] - fila["minimo"]
atr = rango if atr is None else alfa * rango + (1 - alfa) * atr
if i < n or fila["ts"].date() != df["ts"].iloc[i - 1].date():
pos = 0 # sesión nueva
elif pos == 0:
techo = df["maximo"].iloc[i - n:i].max()
if fila["cierre"] > techo:
pos, pico = 1, fila["cierre"] # entrada
else:
pico = max(pico, fila["cierre"])
if fila["cierre"] < pico - k * atr:
pos = 0 # stop dinámico
salida.append(pos)
return np.array(salida, dtype=np.int8)
sub = df.iloc[:20_000]
t0 = time.perf_counter()
pos_ref = backtest_ingenuo(sub)
t_ingenuo = time.perf_counter() - t0 # no se imprime
entradas = int((np.diff(pos_ref, prepend=0) == 1).sum())
print(len(df), "barras,", df["ts"].dt.date.nunique(), "sesiones")
print(f"primeras 20.000: {entradas} entradas, "
f"en mercado el {pos_ref.mean():.1%} de las barras")96330 barras, 247 sesiones
primeras 20.000: 226 entradas, en mercado el 40.7% de las barrasEn la máquina en la que se escribió la lección, las 20.000 primeras barras tardan unos 3,7 segundos: unos 185 microsegundos por barra, unos 18 segundos para el fichero entero. Las celdas no imprimen tiempos porque cambian de una máquina a otra y de una ejecución a otra; guardan el tiempo en una variable y, al comparar, imprimen solo si una versión es más rápida que otra por un margen amplio. Para un backtest que se ejecuta una vez, 18 segundos no importan. Para un barrido de 200 combinaciones de parámetros (lección 2.5.6), o para cien activos, son una hora.
cProfile: cuentas exactas
cProfile es el perfilador determinista de la biblioteca estándar: intercepta cada llamada y cada retorno de función
y anota cuántas veces se llamó cada una y cuánto tardó. Su informe por defecto (pstats) es largo; lo que interesa
aquí son las funciones a las que llama directamente backtest_ingenuo, con su número de llamadas, ordenadas por el
tiempo acumulado. Con 5.000 barras basta:
import cProfile
import pstats
perfil = cProfile.Profile()
perfil.runcall(backtest_ingenuo, df.iloc[:5_000])
st = pstats.Stats(perfil)
raiz = next(f for f in st.stats if f[2] == "backtest_ingenuo")
total = st.stats[raiz][3] # tiempo acumulado
hijos = []
for (fichero, _, nombre), (*_, llamadores) in st.stats.items():
if raiz in llamadores:
_, ncalls, _, acumulado = llamadores[raiz]
corto = fichero.rsplit("site-packages/", 1)[-1]
hijos.append((acumulado, ncalls, f"{corto}:{nombre}"))
hijos.sort(reverse=True)
for acumulado, ncalls, nombre in hijos[:2]:
print(f"{ncalls:>7} {nombre}")
pandas = sum(a for a, _, n in hijos if n.startswith("pandas"))
print("pandas, más del 80 % del tiempo:", pandas / total > 0.8) 13038 pandas/core/indexing.py:__getitem__
8038 pandas/core/frame.py:__getitem__
pandas, más del 80 % del tiempo: TrueLas dos funciones que más tiempo acumulan son las de indexar de pandas: iloc[...] y df[...]. Las cuentas se
pueden reconstruir leyendo el código, y conviene hacerlo: 13.038 llamadas a _iLocIndexer.__getitem__ son las 5.000 de
df.iloc[i], las 4.970 de df["ts"].iloc[i - 1] (todas las barras salvo las 30 primeras) y 3.068 de
df["maximo"].iloc[i - n:i] (solo cuando se está fuera del mercado); las 8.038 de DataFrame.__getitem__ son los
df["ts"] y df["maximo"] de las dos últimas. En la máquina del autor, iloc[...] se lleva más de la mitad del
tiempo, df[...] más de una cuarta parte y la aritmética de la estrategia (el [self] de la función, en el informe
de pyinstrument de más abajo), alrededor del 4 %.
cProfile tiene dos defectos que conviene conocer. Añade un coste fijo a cada llamada de Python, así que deforma más el código con muchas llamadas pequeñas (como este); los porcentajes son orientativos y los tiempos absolutos, más altos que sin perfilar. Y solo mide el hilo desde el que se activa. Lo que da a cambio son cuentas exactas: si una función se llama un millón de veces cuando debería llamarse mil, cProfile lo dice sin ambigüedad.
Perfiladores de muestreo: pyinstrument y py-spy
Un perfilador de muestreo no intercepta llamadas: mira la pila del programa cada cierto tiempo (cada milisegundo, por ejemplo) y cuenta en qué funciones estaba. Una función que aparece en el 50 % de las muestras se llevó, aproximadamente, la mitad del tiempo. Pierde las cuentas exactas y las funciones muy cortas, pero interfiere mucho menos con el programa.
pyinstrument muestrea desde dentro del proceso y presenta un árbol de llamadas. Su salida de texto lleva la hora,
el número de muestras y los segundos de cada rama, todo variable; la celda recorre el árbol y escribe solo las ramas
bajo backtest_ingenuo que superan el 20 % del tiempo:
from pyinstrument import Profiler
from pyinstrument.renderers import ConsoleRenderer
pf = Profiler(interval=0.001)
pf.start()
backtest_ingenuo(df.iloc[:5_000])
pf.stop()
arbol = ConsoleRenderer().preprocess(pf.last_session.root_frame())
def buscar(marco, nombre):
if marco.function == nombre:
return marco
for hijo in marco.children:
if (r := buscar(hijo, nombre)) is not None:
return r
b = buscar(arbol, "backtest_ingenuo")
for hijo in b.children:
if hijo.time / b.time > 0.2:
nombre = f"{hijo.class_name}.{hijo.function}"
print(nombre, hijo.file_path_short)_iLocIndexer.__getitem__ pandas/core/indexing.py
DataFrame.__getitem__ pandas/core/frame.pyLas mismas dos ramas que cProfile. El informe completo de pyinstrument, tal como lo imprimió output_text() al
ejecutar este mismo código como script (pyi.py) en la máquina del autor (la ruta del perfil, acortada; los tiempos
cambian en cada ejecución), muestra el árbol agrupado, con los marcos internos de pandas plegados:
print(pf.output_text(unicode=True, color=False)) _ ._ __/__ _ _ _ _ _/_ Recorded: 23:43:42 Samples: 1774
/_//_/// /_\ / //_// / //_'/ // Duration: 1.774 CPU time: 1.753
/ _/ v5.1.3
Profile at pyi.py:33
1.773 <module> pyi.py:1
└─ 1.773 backtest_ingenuo pyi.py:9
├─ 0.962 _iLocIndexer.__getitem__ pandas/core/indexing.py:1192
│ [38 frames hidden] pandas, typing, annotationlib, <built...
├─ 0.498 DataFrame.__getitem__ pandas/core/frame.py:4337
│ [20 frames hidden] pandas, typing, annotationlib
├─ 0.128 wrapper pandas/util/_decorators.py:328
│ [15 frames hidden] pandas, numpy, <built-in>
├─ 0.112 Series.__getitem__ pandas/core/series.py:945
│ [5 frames hidden] pandas
└─ 0.067 [self] pyi.pypy-spy muestrea desde fuera del proceso: es un programa aparte (escrito en Rust) que lee la memoria del intérprete.
Por eso no hace falta tocar el código y sirve para un bot que ya está en marcha. En Linux, leer la memoria de otro
proceso exige permisos: con kernel.yama.ptrace_scope = 1, la configuración habitual, solo se puede muestrear un
proceso hijo, así que py-spy lanza el script él mismo. La celda escribe un script con el mismo backtest (sacado con
inspect.getsource), lo ejecuta bajo py-spy con 200 muestras por segundo y cuenta, en el formato raw (una línea por
pila distinta: los marcos separados por ; y el número de muestras), qué función del script está más abajo en la pila
en cada muestra, sin contar el nivel del módulo (<module>):
import inspect
import subprocess
from collections import Counter
from pathlib import Path
Path("perfilar.py").write_text(
"import numpy as np\nimport pandas as pd\n\n"
"N, K, ALFA = 30, 4.0, 2 / 15\n\n"
+ inspect.getsource(backtest_ingenuo)
+ "\n\ndef cargar():\n"
" return pd.read_parquet('hf5_barras_1min.parquet')\n\n"
"backtest_ingenuo(cargar().iloc[:5_000])\n")
r = subprocess.run(["py-spy", "record", "--nonblocking", "-r", "200",
"-f", "raw", "-o", "perfil.txt", "--",
"python", "perfilar.py"],
capture_output=True, text=True)
cuenta = Counter()
for linea in Path("perfil.txt").read_text().splitlines():
pila, n = linea.rsplit(" ", 1)
propios = [m.split(" (")[0] for m in pila.split(";")
if "(perfilar.py:" in m and "<module>" not in m]
if propios:
cuenta[propios[-1]] += int(n)
print("py-spy:", r.returncode, "| función propia más muestreada:",
cuenta.most_common(1)[0][0])py-spy: 0 | función propia más muestreada: backtest_ingenuoCon Python 3.14 y py-spy 0.4.2, en esta máquina, muchas muestras de --nonblocking salen vacías o cortadas: el proceso
sigue corriendo mientras py-spy lee su pila, y a veces la lee a medias. Por eso la celda descarta el nivel del módulo,
al que van a parar las pilas cortadas (y la importación de pandas), y compara solo las funciones del script. El modo
bloqueante, que detiene el proceso en cada muestra, es fiable pero ralentiza decenas de veces este script. Para
preguntas gruesas (qué función propia se lleva el tiempo) basta; para porcentajes finos, mejor pyinstrument o cProfile.
El perfilador de muestreo de Python 3.15
Python 3.15 (publicada el 9 de octubre de 20261Nota 1 · Python.org, «Python 3.15.0» (https://www.python.org/downloads/release/python-3150/): fecha de publicación, 9 de
octubre de 2026. Consultada el 23 de octubre de 2026.) reorganiza los perfiladores en un paquete nuevo, profiling
(PEP 7992Nota 2 · PEP 799, «A dedicated profiling package for organizing Python profiling tools»
(https://peps.python.org/pep-0799/). Consultada el 23 de octubre de 2026.): profiling.tracing es el cProfile de siempre con otro nombre, el viejo módulo profile (en Python puro)
queda obsoleto y profiling.sampling, apodado Tachyon, es un perfilador de muestreo como py-spy pero incluido en el
intérprete3Nota 3 · Documentación de Python 3.15, «profiling.sampling — Statistical profiler»
(https://docs.python.org/3.15/library/profiling.sampling.html): modos, frecuencia por defecto, comandos run,
attach, dump y replay, formatos de salida y requisitos de plataforma. Consultada el 23 de octubre de 2026.. Según su documentación:
- Lee la pila desde fuera del proceso, sin detenerlo, con un coste para el programa que la documentación describe como
prácticamente nulo. Por defecto toma 1.000 muestras por segundo (
-r); se puede limitar la duración (-d). - Tiene cuatro modos: tiempo de reloj (
--mode=wall, por defecto, incluye esperas de red y disco), CPU (--mode=cpu, solo cuando el hilo está ejecutando), GIL (--mode=gil, solo cuando tiene el GIL: separa Python de las extensiones en C) y excepciones. - Puede lanzar un script (
run), engancharse a un proceso por su PID (attach) o leer una sola instantánea de la pila (dump, útil con un proceso colgado), y escribe pstats, pilas colapsadas (el mismo formato que elrawde py-spy), flame graphs en HTML, mapas de calor por línea o el formato del Firefox Profiler. - En Linux,
attachydumptienen la misma restricción deptrace_scopeque py-spy; el perfilador y el proceso deben ser de la misma versión menor de Python (y de la misma versión exacta si alguno es una preversión).
El entorno del curso es Python 3.14 (uv, en esta máquina, solo ofrece 3.15.0rc2), así que el código siguiente no se ejecuta. Con Python 3.15, la celda de py-spy se reescribe sin instalar nada, y el recuento es el mismo porque el formato colapsado es el mismo:
# Python 3.15: muestreo de la biblioteca estándar
r = subprocess.run([sys.executable, "-m", "profiling.sampling",
"run", "--collapsed", "-r", "10khz",
"perfilar.py"], capture_output=True, text=True)
# una línea por pila: «marco;marco;marco N», como el raw de py-spy
# Un bot en marcha (mismo usuario, permisos de ptrace):
# python -m profiling.sampling attach --mode=cpu -d 30 <PID>
# python -m profiling.sampling dump <PID> # ¿dónde está colgado?Para un sistema en vivo (lección 4.5.1), dump responde a una pregunta frecuente a las tres de la madrugada: «¿qué
está haciendo ahora mismo el proceso que no responde?». Hasta ahora eso exigía py-spy o gdb.
Primero, sacar a pandas del bucle
El perfil dice que el problema es pedirle a pandas una fila y un trozo de columna en cada vuelta. Lo que no depende de la trayectoria se calcula fuera, con operaciones de columnas: el máximo de las 30 barras anteriores es un máximo móvil desplazado una barra, y el cambio de sesión es comparar la fecha con la de la barra anterior. El bucle recibe solo arrays de NumPy:
Fuente Mermaid
flowchart LR P[preparar: pandas] --> A[arrays de NumPy] A --> B[bucle compilable] B --> S[posiciones]
def preparar(df, n=N):
techo = df["maximo"].rolling(n).max().shift().to_numpy()
dia = df["ts"].dt.floor("D")
nueva = (dia != dia.shift()).to_numpy()
return (df["maximo"].to_numpy(), df["minimo"].to_numpy(),
df["cierre"].to_numpy(), techo, nueva)
def bucle(maximo, minimo, cierre, techo, nueva, n=N, k=K, alfa=ALFA):
m = len(cierre)
salida = np.zeros(m, dtype=np.int8)
pos, pico, atr = 0, 0.0, 0.0
for i in range(m):
rango = maximo[i] - minimo[i]
atr = rango if i == 0 else alfa * rango + (1 - alfa) * atr
if i < n or nueva[i]:
pos = 0
elif pos == 0:
if cierre[i] > techo[i]:
pos, pico = 1, cierre[i]
else:
if cierre[i] > pico:
pico = cierre[i]
if cierre[i] < pico - k * atr:
pos = 0
salida[i] = pos
return salida
import timeit
def medir(f, *args, veces=1, rep=5):
"""Mejor tiempo por llamada de `rep` repeticiones."""
return min(timeit.repeat(lambda: f(*args), number=veces,
repeat=rep)) / veces
arrays = preparar(sub)
print("idéntico al ingenuo:", np.array_equal(bucle(*arrays), pos_ref))
t_arrays = medir(bucle, *arrays)
print("más de 50 veces más rápido:", t_ingenuo / t_arrays > 50)idéntico al ingenuo: True
más de 50 veces más rápido: TrueMismo resultado, barra a barra, y más de 50 veces más rápido (en la máquina del autor, unas 280 veces). La
comparación con el resultado de referencia es la parte importante: una optimización que cambia una sola posición no es
una optimización, es otro backtest. Dos detalles de equivalencia: el techo desplazado una barra reproduce
iloc[i - n:i], que no incluye la barra actual, y en las primeras 30 barras vale NaN, que nunca es menor que un cierre,
igual que en la versión original, que no las evalúa.
El bucle sigue siendo Python: cada maximo[i] crea un objeto float de Python a partir del array, cada suma crea otro.
Por eso, curiosamente, el mismo bucle con listas de Python (arr.tolist()) va todavía algo más rápido que con arrays:
NumPy es rápido con operaciones sobre arrays enteros, no con elementos sueltos. Para ir más allá hay que salir del
intérprete.
Numba: el mismo bucle, compilado
Numba compila funciones de Python a código máquina con LLVM en el momento de la primera llamada, a partir de los tipos
de los argumentos4Nota 4 · Numba, «Compiling Python code with @jit» (https://numba.readthedocs.io/en/stable/user/jit.html). Consultada el
23 de octubre de 2026.. En modo nopython (el de @njit) el resultado no usa el intérprete para nada: los números son
números de la máquina y los arrays, memoria contigua. La función bucle no hay que reescribirla:
from numba import njit
bucle_nb = njit(cache=True)(bucle)
completo = preparar(df)
pos_nb = bucle_nb(*completo) # compila aquí
pos_py = bucle(*completo)
print("idéntico al bucle de Python en las 96.330 barras:",
np.array_equal(pos_nb, pos_py))
t_py = medir(bucle, *completo, rep=3)
t_nb = medir(bucle_nb, *completo, veces=20)
print("Numba, más de 20 veces más rápido que el bucle de Python:",
t_py / t_nb > 20)idéntico al bucle de Python en las 96.330 barras: True
Numba, más de 20 veces más rápido que el bucle de Python: TrueEn la máquina del autor, unas 240 veces: el bucle sobre las 96.330 barras pasa de unos 58 milisegundos a unos 0,24.
La primera llamada compila (en torno a medio segundo para una función así) y cache=True guarda el resultado en disco,
de modo que el siguiente proceso no vuelve a compilar mientras no cambien el código ni los tipos. Por eso la celda mide
después de la primera llamada.
Lo que Numba no puede compilar falla con un error de tipos, no con un programa lento. Pasarle el DataFrame en vez de los arrays:
from numba.core.errors import TypingError
ingenuo_nb = njit(backtest_ingenuo)
try:
ingenuo_nb(df.iloc[:100])
except TypingError as e:
print(type(e).__name__ + ":", str(e).splitlines()[0])TypingError: Failed in nopython mode pipeline (step: nopython frontend)Numba entiende números, arrays de NumPy, tuplas, algunos diccionarios y listas tipados y una parte grande de NumPy; no
entiende pandas ni objetos arbitrarios. Eso marca el diseño: la frontera entre pandas y el código compilado son
arrays, como en preparar. Tres precauciones más. fastmath=True acelera algunas operaciones permitiendo reordenar
sumas en coma flotante, lo que cambia los últimos decimales: en un backtest que compara con un resultado de referencia,
mejor no usarlo. parallel=True con prange reparte iteraciones independientes entre hilos, y este bucle no las
tiene (cada barra depende de la anterior), pero un barrido de parámetros sí (ejercicio 1). Y el código compilado no
comprueba los índices fuera de rango por defecto: un error que en Python lanzaría IndexError puede leer memoria
ajena sin avisar.
Cython: tipos declarados y C
Cython traduce un dialecto de Python con tipos de C a una extensión de C que se compila con el compilador del sistema
(aquí, gcc). Sin declarar tipos, el código traducido sigue llamando a la API de Python en cada operación y gana poco;
con tipos (cdef double, vistas de memoria double[:] sobre los arrays), el bucle se convierte en C puro5Nota 5 · Cython, «Typed Memoryviews» (https://cython.readthedocs.io/en/latest/src/userguide/memoryviews.html).
Consultada el 23 de octubre de 2026.. La celda
escribe la versión tipada en un fichero .pyx, la anota con cython -a junto a una copia sin tipos de bucle y
cuenta, en cada anotación, cuántas líneas del cuerpo del bucle llaman a la API de Python (las que Cython marca en
amarillo en el HTML):
import re
import sys
PYX = '''# cython: boundscheck=False, wraparound=False
import numpy as np
def bucle_cy(const double[:] maximo, const double[:] minimo,
const double[:] cierre, const double[:] techo,
const unsigned char[:] nueva, int n=30,
double k=4.0, double alfa=2.0 / 15):
cdef Py_ssize_t i, m = cierre.shape[0]
cdef double rango, pico = 0.0, atr = 0.0
cdef signed char pos = 0
salida = np.zeros(m, dtype=np.int8)
cdef signed char[:] s = salida
for i in range(m):
rango = maximo[i] - minimo[i]
atr = rango if i == 0 else alfa * rango + (1 - alfa) * atr
if i < n or nueva[i]:
pos = 0
elif pos == 0:
if cierre[i] > techo[i]:
pos = 1
pico = cierre[i]
else:
if cierre[i] > pico:
pico = cierre[i]
if cierre[i] < pico - k * atr:
pos = 0
s[i] = pos
return salida
'''
Path("bucle_cy.pyx").write_text(PYX)
Path("bucle_sin_tipos.pyx").write_text(
"import numpy as np\n\n" + inspect.getsource(bucle)
.replace("n=N, k=K, alfa=ALFA", "n=30, k=4.0, alfa=2 / 15"))
def lineas_con_python(nombre):
"""Líneas del cuerpo del bucle con llamadas a la API de Python."""
subprocess.run(["cython", "-3", "-a", f"{nombre}.pyx"], check=True)
html = Path(f"{nombre}.html").read_text()
puntos = [int(p) for p in
re.findall(r'class="cython line score-(\d+)"', html)]
codigo = Path(f"{nombre}.pyx").read_text().splitlines()
ini = next(i for i, l in enumerate(codigo) if "for i in" in l)
fin = next(i for i, l in enumerate(codigo) if "return" in l)
return sum(p > 0 for p in puntos[ini + 1:fin]), fin - ini - 1
for nombre in ("bucle_sin_tipos", "bucle_cy"):
con, total = lineas_con_python(nombre)
print(f"{nombre}: {con} de {total} líneas del bucle usan Python")bucle_sin_tipos: 9 de 13 líneas del bucle usan Python
bucle_cy: 0 de 14 líneas del bucle usan PythonSin tipos, nueve de las trece líneas del bucle pasan por objetos de Python; con tipos, ninguna. La anotación es la
herramienta de trabajo con Cython: se escribe, se anota, se miran las líneas amarillas dentro de los bucles y se
añaden tipos hasta que desaparecen. Las directivas de la primera línea quitan las comprobaciones de índices
(boundscheck) y de índices negativos (wraparound): son más rápidas y tan peligrosas como en Numba. pyximport
compila el .pyx al importarlo, en una carpeta temporal:
import pyximport
sys.path.insert(0, ".")
pyximport.install(build_dir="build_pyx", language_level=3)
from bucle_cy import bucle_cy
maximo, minimo, cierre, techo, nueva = completo
pos_cy = bucle_cy(maximo, minimo, cierre, techo, nueva.view(np.uint8))
print("idéntico a Numba:", np.array_equal(pos_cy, pos_nb))
t_cy = medir(bucle_cy, maximo, minimo, cierre, techo,
nueva.view(np.uint8), veces=20)
print("Cython, del mismo orden que Numba (entre 0,2 y 5 veces):",
0.2 < t_nb / t_cy < 5)idéntico a Numba: True
Cython, del mismo orden que Numba (entre 0,2 y 5 veces): TrueMismo resultado y el mismo orden de magnitud que Numba. En la máquina del autor, con el fichero entero, Cython tarda
unos 0,19 milisegundos y Numba unos 0,24; con 20.000 barras, Cython sale unas 2,7 veces más rápido. Las diferencias
entre dos versiones compiladas dependen del tamaño y de detalles como el coste fijo de cada llamada; lo que importa es
que las dos están cientos de veces por delante del bucle de Python. nueva.view(np.uint8) reinterpreta el array
booleano como bytes sin copiarlo, porque las vistas de memoria de Cython no aceptan bool directamente. Para distribuir
un paquete con Cython no se usa pyximport sino el sistema de construcción del paquete: un setup.py con cythonize o
un backend como setuptools declarado en pyproject.toml, que compila al instalar y produce ruedas por plataforma.
¿Numba o Cython? Para un bucle numérico de research, Numba: es Python, no hay paso de compilación aparte y no hace falta compilador en la máquina. Cython conviene cuando hay que llamar a bibliotecas de C, mezclar código compilado con objetos de Python o distribuir una extensión sin que el usuario instale LLVM; es lo que usan pandas, scikit-learn o NautilusTrader en partes de su núcleo (lección 2.4.5).
Rust con PyO3: lo que hay que saber
Rust es un lenguaje compilado con seguridad de memoria garantizada por el compilador: sin el boundscheck=False de
Cython ni accesos fuera de rango silenciosos (en Rust, un índice fuera de rango detiene el programa con un error). PyO3
genera los enlaces con Python y maturin construye y publica las ruedas6Nota 6 · PyO3 (https://pyo3.rs/) y su README, con el ejemplo de Cargo.toml y #[pyo3::pymodule] para la versión
0.30; rust-numpy (https://github.com/PyO3/rust-numpy) para PyReadonlyArray1 e IntoPyArray; versiones en
crates.io. Consultada el 23 de octubre de 2026.. Media pila del ecosistema de Python
cuantitativo ya está escrita así: Polars (lección 0.5.4), el núcleo de NautilusTrader, Ruff, uv y el propio py-spy.
El mismo bucle como extensión de Rust. Compilarlo exige descargar crates (dependencias de Rust) de crates.io, y el curso no usa la red, así que el código va como lectura, con las versiones publicadas a 23 de octubre de 2026 (pyo3 0.30.1, numpy 0.30.0):
# Cargo.toml
[package]
name = "bucle_rust"
version = "0.1.0"
edition = "2024"
[lib]
name = "bucle_rust"
crate-type = ["cdylib"]
[dependencies]
pyo3 = "0.30"
numpy = "0.30"// src/lib.rs
#[pyo3::pymodule]
mod bucle_rust {
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
#[pyfunction]
#[pyo3(signature = (maximo, minimo, cierre, techo, nueva,
n=30, k=4.0, alfa=2.0 / 15.0))]
fn bucle_rs<'py>(
py: Python<'py>,
maximo: PyReadonlyArray1<'py, f64>,
minimo: PyReadonlyArray1<'py, f64>,
cierre: PyReadonlyArray1<'py, f64>,
techo: PyReadonlyArray1<'py, f64>,
nueva: PyReadonlyArray1<'py, bool>,
n: usize, k: f64, alfa: f64,
) -> PyResult<Bound<'py, PyArray1<i8>>> {
let (mx, mn) = (maximo.as_slice()?, minimo.as_slice()?);
let (c, t) = (cierre.as_slice()?, techo.as_slice()?);
let nv = nueva.as_slice()?;
let mut salida = vec![0i8; c.len()];
let (mut pos, mut pico, mut atr) = (0i8, 0.0f64, 0.0f64);
for i in 0..c.len() {
let rango = mx[i] - mn[i];
atr = if i == 0 { rango }
else { alfa * rango + (1.0 - alfa) * atr };
if i < n || nv[i] {
pos = 0;
} else if pos == 0 {
if c[i] > t[i] { pos = 1; pico = c[i]; }
} else {
if c[i] > pico { pico = c[i]; }
if c[i] < pico - k * atr { pos = 0; }
}
salida[i] = pos;
}
Ok(salida.into_pyarray(py))
}
}# pip/uv: maturin en el entorno de desarrollo; necesita rustc y red
# $ maturin develop --release (compila e instala en el venv)
from bucle_rust import bucle_rs
pos_rs = bucle_rs(*completo)
assert np.array_equal(pos_rs, pos_nb)as_slice() falla si el array no es contiguo en memoria, en vez de leerlo mal; las comparaciones con NaN dan false
igual que en Python, así que el techo de las primeras barras se comporta igual. La velocidad sería del orden de Cython
y Numba: los tres generan código máquina para el mismo bucle. La diferencia está en lo demás:
| Numba | Cython | Rust (PyO3) | |
|---|---|---|---|
| Lenguaje | Python (subconjunto) | Python con tipos de C | Rust |
| Compilación | al ejecutar (LLVM) | al instalar (compilador de C) | al instalar (rustc) |
| Seguridad de memoria | sin comprobación de índices por defecto | según las directivas | garantizada por el compilador |
| Distribución | depende de Numba y llvmlite | rueda por plataforma | rueda por plataforma (maturin) |
| Adecuado para | bucles numéricos de research | extensiones que mezclan C y Python | bibliotecas grandes y concurrentes |
Para un bucle de un backtest, Rust es una herramienta desproporcionada: hay que aprender un lenguaje nuevo y mantener una cadena de compilación. Tiene sentido cuando se construye una biblioteca que otros van a usar durante años, con estructuras de datos propias, hilos sin GIL y garantías de que no habrá corrupción de memoria: un motor de backtest, un gestor de órdenes de baja latencia o un lector de un formato de datos binario.
Cuándo parar
Con el bucle compilado, el reparto del tiempo ha cambiado. Preparar los arrays (el máximo móvil y las fechas, con pandas) tarda unos 5 milisegundos; el bucle con Numba, unas décimas. La ley de Amdahl dice lo que eso significa:
t_prep = medir(preparar, df, rep=3)
print("preparar ya tarda más que el bucle compilado:", t_prep > t_nb)
def amdahl(p, s):
return 1 / ((1 - p) + p / s)
for p in (0.5, 0.9, 0.99):
fila = [f"{amdahl(p, s):7.1f}" for s in (10, 100, 1000)]
print(f"p = {p:<5}", *fila)preparar ya tarda más que el bucle compilado: True
p = 0.5 1.8 2.0 2.0
p = 0.9 5.3 9.2 9.9
p = 0.99 9.2 50.3 91.0En la tabla, cada fila es una fracción del tiempo y cada columna, cuántas veces se acelera esa parte (10, 100 y 1.000). Con , acelerar mil veces da menos de 2. La figura lo dibuja para un rango continuo. En el backtest de la lección, la primera versión pasaba más del 99 % del tiempo en el bucle, y sacar a pandas de él lo cambió todo; ahora el bucle es una fracción pequeña y acelerarlo más no cambiaría el total. Lo siguiente sería perfilar el programa completo (lectura del Parquet, preparación, análisis de resultados) y ver si el total importa. Para un backtest de cien mil barras que tarda ahora unos milisegundos, no: se para aquí.
La figura de la cabecera resume el camino en la máquina del autor: cada versión, con su aceleración frente a la original en las mismas 20.000 barras. Las cifras exactas cambian con la máquina; los órdenes de magnitud, no.
Ejercicios
- Un barrido de parámetros sí es paralelizable: cada combinación es independiente. Escribe una función de Numba con
parallel=Trueyprangeque, para varios valores dek, calcule la rentabilidad logarítmica total de la estrategia (posición de la barra anterior por la rentabilidad de la barra) sobre las 96.330 barras.
Solución
from numba import prange
@njit(parallel=True, cache=True)
def barrido(maximo, minimo, cierre, techo, nueva, ks, n=30,
alfa=2 / 15):
r = np.zeros(len(ks))
for j in prange(len(ks)):
pos = bucle_nb(maximo, minimo, cierre, techo, nueva,
n, ks[j], alfa)
for i in range(1, len(cierre)):
r[j] += pos[i - 1] * np.log(cierre[i] / cierre[i - 1])
return r
ks = np.array([1.0, 2.0, 3.0, 4.0, 6.0, 8.0])
for k, r in zip(ks, barrido(*completo, ks)):
print(f"k = {k:3.0f}: rentabilidad log total {r:+.4f}")k = 1: rentabilidad log total -0.0416
k = 2: rentabilidad log total +0.0513
k = 3: rentabilidad log total +0.0703
k = 4: rentabilidad log total +0.1508
k = 6: rentabilidad log total +0.1871
k = 8: rentabilidad log total +0.2855Una función de Numba puede llamar a otra (bucle_nb) dentro del código compilado. Cada k escribe solo en su
posición de r, así que no hay condiciones de carrera, y la suma de cada k se hace en un solo hilo y en orden: el
resultado es el mismo con uno o con cuatro hilos. Las cifras son de datos sintéticos y no dicen nada de la
estrategia, y elegir k por el máximo de esta tabla sería sobreajustar (lección 2.5.4).
- Sin tocar la lógica, quita del backtest ingenuo las indexaciones de columnas dentro del bucle (
df["ts"]ydf["maximo"]) sacándolas antes delfor, y mide con cProfile cuántas llamadas aDataFrame.__getitem__quedan.
Solución
def backtest_columnas(df, n=N, k=K, alfa=ALFA):
ts, maximo = df["ts"], df["maximo"] # una vez
pos, pico, atr = 0, 0.0, None
salida = []
for i in range(len(df)):
fila = df.iloc[i]
rango = fila["maximo"] - fila["minimo"]
atr = rango if atr is None else alfa * rango + (1 - alfa) * atr
if i < n or fila["ts"].date() != ts.iloc[i - 1].date():
pos = 0
elif pos == 0:
if fila["cierre"] > maximo.iloc[i - n:i].max():
pos, pico = 1, fila["cierre"]
else:
pico = max(pico, fila["cierre"])
if fila["cierre"] < pico - k * atr:
pos = 0
salida.append(pos)
return np.array(salida, dtype=np.int8)
perfil = cProfile.Profile()
pos5 = perfil.runcall(backtest_columnas, df.iloc[:5_000])
st = pstats.Stats(perfil)
llamadas = {f[2]: v[1] for f, v in st.stats.items()
if f[0].endswith("frame.py")}
print("DataFrame.__getitem__:", llamadas.get("__getitem__"))
print("mismo resultado:",
np.array_equal(pos5, backtest_ingenuo(df.iloc[:5_000])))DataFrame.__getitem__: 2
mismo resultado: TrueQuedan 2 llamadas de las 8.038: las dos de antes del bucle. El resultado no cambia y el backtest se ahorra lo que
cProfile atribuía a DataFrame.__getitem__, más de una cuarta parte del tiempo en la máquina del autor. Es el primer
paso de la reescritura de la lección, hecho con el perfil en la mano: quitar del bucle lo que no depende de la vuelta.
- Un compañero quiere reescribir en Rust el bucle de su estrategia, que tarda 2 segundos dentro de un proceso de reentrenamiento nocturno de 40 minutos. ¿Qué le dirías?
Solución
Que mida antes. Con la ley de Amdahl, el bucle es el 2/2.400 ≈ 0,08 % del proceso: aunque tardara cero, el
reentrenamiento seguiría durando 40 minutos menos 2 segundos. Lo primero es perfilar el proceso entero (con
pyinstrument, py-spy o, con Python 3.15, profiling.sampling) para saber dónde están los 40 minutos. Si de verdad
hiciera falta acelerar ese bucle, Numba lo haría en una línea sin cambiar de lenguaje; Rust tendría sentido si el
bucle formara parte de una biblioteca que va a mantenerse años, con varios hilos o estructuras de datos propias.
Footnotes
-
Python.org, «Python 3.15.0» (https://www.python.org/downloads/release/python-3150/): fecha de publicación, 9 de octubre de 2026. Consultada el 23 de octubre de 2026. ↩
-
PEP 799, «A dedicated
profilingpackage for organizing Python profiling tools» (https://peps.python.org/pep-0799/). Consultada el 23 de octubre de 2026. ↩ -
Documentación de Python 3.15, «profiling.sampling — Statistical profiler» (https://docs.python.org/3.15/library/profiling.sampling.html): modos, frecuencia por defecto, comandos
run,attach,dumpyreplay, formatos de salida y requisitos de plataforma. Consultada el 23 de octubre de 2026. ↩ -
Numba, «Compiling Python code with @jit» (https://numba.readthedocs.io/en/stable/user/jit.html). Consultada el 23 de octubre de 2026. ↩
-
Cython, «Typed Memoryviews» (https://cython.readthedocs.io/en/latest/src/userguide/memoryviews.html). Consultada el 23 de octubre de 2026. ↩
-
PyO3 (https://pyo3.rs/) y su README, con el ejemplo de
Cargo.tomly#[pyo3::pymodule]para la versión 0.30; rust-numpy (https://github.com/PyO3/rust-numpy) paraPyReadonlyArray1eIntoPyArray; versiones en crates.io. Consultada el 23 de octubre de 2026. ↩
Respuesta plegada en cada pregunta
Autoevaluación
El 90 % del tiempo de un programa está en una función. La aceleras 100 veces. ¿Cuánto más rápido va el programa entero, según la ley de Amdahl?
- A100 veces
- B90 veces
- CUnas 9,2 veces: el 10 % que no se tocó pasa a dominar
- DUnas 50 veces
Ver respuesta
Respuesta C. Unas 9,2 veces: el 10 % que no se tocó pasa a dominar
S = 1 / ((1 − p) + p/s) = 1 / (0,1 + 0,9/100) ≈ 9,17. Aunque la función fuera infinitamente rápida, el programa no pasaría de 10 veces. Por eso se mide primero qué fracción ocupa lo que se quiere acelerar.
cProfile cuenta 13.038 llamadas a
_iLocIndexer.__getitem__al recorrer 5.000 barras. ¿De dónde salen?- ASon un artefacto del perfilador
- BDe
df.iloc[i](5.000),df["ts"].iloc[i - 1](4.970) ydf["maximo"].iloc[i - n:i](3.068): cada barra paga dos o tres indexaciones de pandas - CDe
np.arrayal final - DDe la lectura del Parquet
Ver respuesta
Respuesta B. De
df.iloc[i](5.000),df["ts"].iloc[i - 1](4.970) ydf["maximo"].iloc[i - n:i](3.068): cada barra paga dos o tres indexaciones de pandasUn perfilador determinista cuenta cada llamada exactamente, y las cuentas se pueden reconstruir desde el código. Ahí está el diagnóstico: el tiempo no se va en la aritmética de la estrategia sino en pedirle a pandas una fila y un trozo de columna en cada vuelta.
¿Por qué la versión con Numba no puede recibir el DataFrame directamente?
- APorque Numba compila en modo nopython: solo entiende tipos que sabe traducir a código máquina (números, arrays de NumPy, tuplas…), no objetos de pandas
- BPorque Numba solo funciona en GPU
- CPorque Numba no admite bucles
- DPorque el DataFrame es demasiado grande
Ver respuesta
Respuesta A. Porque Numba compila en modo nopython: solo entiende tipos que sabe traducir a código máquina (números, arrays de NumPy, tuplas…), no objetos de pandas
Numba infiere los tipos de los argumentos y genera código con LLVM; un DataFrame es un objeto de Python que no sabe tipar y la compilación falla con
TypingError. Las columnas se pasan como arrays con.to_numpy().¿Qué ventaja tiene un perfilador de muestreo (pyinstrument, py-spy,
profiling.samplingde Python 3.15) frente a uno determinista como cProfile?- ADa el número exacto de llamadas
- BEs más preciso en funciones muy cortas
- CSolo funciona con Python 3.15
- DInterfiere poco con el programa: no instrumenta cada llamada, así que el perfil se parece al programa sin perfilar, y algunos se pueden enganchar a un proceso en marcha
Ver respuesta
Respuesta D. Interfiere poco con el programa: no instrumenta cada llamada, así que el perfil se parece al programa sin perfilar, y algunos se pueden enganchar a un proceso en marcha
cProfile mide cada llamada y retorno, lo que añade un coste fijo por llamada y penaliza más al código con muchas llamadas pequeñas. El muestreo mira la pila de vez en cuando: pierde las cuentas exactas, pero deforma menos y sirve en producción.
Después de compilar el bucle con Numba, preparar los arrays (máximos móviles, cambios de sesión) tarda más que el bucle. ¿Qué tiene sentido hacer ahora?
- AReescribir el bucle en Rust
- BCompilar también el bucle con Cython
- CVolver a medir el programa entero: si va lo bastante rápido, parar; si no, perfilar de nuevo, porque el cuello de botella ya está en otro sitio
- DAumentar el número de hilos de Numba
Ver respuesta
Respuesta C. Volver a medir el programa entero: si va lo bastante rápido, parar; si no, perfilar de nuevo, porque el cuello de botella ya está en otro sitio
Es la ley de Amdahl en la práctica: cuando el bucle cuesta décimas de milisegundo, acelerarlo más no cambia nada. El siguiente paso lo dice un perfil nuevo, no la intuición.
Ideas clave
- Primero se mide el programa entero y se perfila; después se optimiza lo que el perfil señala, y se vuelve a medir. La ley de Amdahl dice cuánto se puede ganar como máximo.
- cProfile da cuentas exactas de llamadas (útiles para reconstruir qué hace el código); pyinstrument y py-spy muestrean la pila con menos interferencia. Python 3.15 trae su propio perfilador de muestreo,
profiling.sampling. - En el backtest de la lección, más del 80 % del tiempo estaba en indexar pandas fila a fila. Sacar a pandas del bucle, con los mismos resultados, lo aceleró dos órdenes de magnitud.
- Numba compila el mismo bucle de Python sobre arrays de NumPy y Cython lo compila con tipos declarados; los dos dan los mismos resultados y van cientos de veces más rápido que el bucle de Python.
- Rust con PyO3 y maturin es la opción para bibliotecas grandes, distribuibles y seguras en memoria (Polars, Ruff, uv, py-spy); para un bucle de research, Numba basta.
- Los tiempos no se comparten: cambian con la máquina. Se comparten los órdenes de magnitud y las comprobaciones de que el resultado no cambia.