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.

Fig. — El mismo backtest de ruptura en cinco versiones, sobre las mismas 20.000 barras y con el mismo resultado: el salto grande es sacar a pandas del bucle; compilar añade dos órdenes de magnitud más
Lecciones del nivel 7
  1. 7.1 · Ingeniería de software para trading

    1. 7.1.1 Estructura de proyecto, configuración y calidad: del notebook al repositorio
    2. 7.1.2 Rendimiento: medir, perfilar y compilar solo lo que hace falta
    3. 7.1.3 Patrones: eventos, colas de mensajes y arquitectura hexagonal
  2. 7.2 · Infraestructura y despliegue

    1. 7.2.1 Docker y docker-compose: imágenes reproducibles con uv
    2. 7.2.2 VPS, nube y latencia; CI/CD con GitHub Actions
    3. 7.2.3 Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos
    4. 7.2.4 Seguridad y continuidad: 2FA, IP, copias y procedimientos ante fallos
  3. 7.3 · MLOps para modelos de trading

    1. 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
    2. 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
    3. 7.3.3 Monitorización del modelo en vivo y desactivación automática
    4. 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
  4. 7.4 · Regulación, ética y fiscalidad

    1. 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
    2. 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
    3. 7.4.3 PRIIPs, KID y ETFs estadounidenses
    4. 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
    5. 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
    6. 7.4.6 Fiscalidad del trading en España
    7. 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
  5. 7.5 · Carrera profesional quant

    1. 7.5.1 Roles del quant: seis oficios alrededor de un mismo sistema
    2. 7.5.2 Dónde trabajar: qué hace cada empresa y qué dicen los registros
    3. 7.5.3 Portfolio público y entrevistas: enseñar criterio, no rentabilidades
    4. 7.5.4 Certificaciones, formación y prop firms: qué certifica cada cosa y qué mide un examen

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:

MedirPerfilarAlgoritmoCompilarMedir otra vez
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 pp del tiempo y se acelera ss veces, el programa entero se acelera

S=1(1−p)+psS = \frac{1}{(1 - p) + \dfrac{p}{s}}

Con p=0,9p = 0{,}9, acelerar esa parte 100 veces da S≈9,2S \approx 9{,}2; ni siquiera con ss infinito se pasa de 10. La figura del final de la lección dibuja la curva para varias pp.

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:

IN [01]python
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")
OUT [01]El backtest, una fila cada vez
96330 barras, 247 sesiones
primeras 20.000: 226 entradas, en mercado el 40.7% de las barras

En 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:

IN [02]python
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)
OUT [02]Lo que llama el backtest, según cProfile
  13038  pandas/core/indexing.py:__getitem__
   8038  pandas/core/frame.py:__getitem__
pandas, más del 80 % del tiempo: True

Las 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:

IN [03]python
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)
OUT [03]pyinstrument: las ramas que pesan
_iLocIndexer.__getitem__ pandas/core/indexing.py
DataFrame.__getitem__ pandas/core/frame.py

Las 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:

IN [04]python
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.py

py-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>):

IN [05]python
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])
OUT [05]py-spy desde fuera del proceso
py-spy: 0 | función propia más muestreada: backtest_ingenuo

Con 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 el raw de py-spy), flame graphs en HTML, mapas de calor por línea o el formato del Firefox Profiler.
  • En Linux, attach y dump tienen la misma restricción de ptrace_scope que 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:

IN [06]python
# 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:

preparar: pandasarrays de NumPybucle compilableposiciones
Fuente Mermaid
flowchart LR
  P[preparar: pandas] --> A[arrays de NumPy]
  A --> B[bucle compilable]
  B --> S[posiciones]
IN [07]python
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)
OUT [07]Lo vectorizable fuera; el bucle sobre arrays
idéntico al ingenuo: True
más de 50 veces más rápido: True

Mismo 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:

IN [08]python
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)
OUT [08]@njit sobre el bucle
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: True

En 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:

IN [09]python
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])
OUT [09]Lo que Numba no sabe tipar
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):

IN [10]python
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")
OUT [10]El bucle en Cython y su anotación
bucle_sin_tipos: 9 de 13 líneas del bucle usan Python
bucle_cy: 0 de 14 líneas del bucle usan Python

Sin 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:

IN [11]python
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)
OUT [11]Compilar e importar con pyximport
idéntico a Numba: True
Cython, del mismo orden que Numba (entre 0,2 y 5 veces): True

Mismo 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):

IN [12]toml
# 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"
IN [13]rust
// 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))
    }
}
IN [14]python
# 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:

NumbaCythonRust (PyO3)
LenguajePython (subconjunto)Python con tipos de CRust
Compilaciónal ejecutar (LLVM)al instalar (compilador de C)al instalar (rustc)
Seguridad de memoriasin comprobación de índices por defectosegún las directivasgarantizada por el compilador
Distribucióndepende de Numba y llvmliterueda por plataformarueda por plataforma (maturin)
Adecuado parabucles numéricos de researchextensiones que mezclan C y Pythonbibliotecas 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:

IN [15]python
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)
OUT [15]Amdahl en el backtest
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.0
Fig. — Ley de Amdahl: aceleración del programa entero según cuánto se acelera la parte optimizada, para tres fracciones p del tiempo. Con p = 0,9, acelerar cien veces da 9,2 y el techo es 10

En la tabla, cada fila es una fracción pp del tiempo y cada columna, cuántas veces se acelera esa parte (10, 100 y 1.000). Con p=0,5p = 0{,}5, 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

  1. Un barrido de parámetros sí es paralelizable: cada combinación es independiente. Escribe una función de Numba con parallel=True y prange que, para varios valores de k, 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
IN [16]python
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}")
OUT [16]Barrido de k con prange
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.2855

Una 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).

  1. Sin tocar la lógica, quita del backtest ingenuo las indexaciones de columnas dentro del bucle (df["ts"] y df["maximo"]) sacándolas antes del for, y mide con cProfile cuántas llamadas a DataFrame.__getitem__ quedan.
Solución
IN [17]python
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])))
OUT [17]Columnas fuera del bucle
DataFrame.__getitem__: 2
mismo resultado: True

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

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

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

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

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

  4. Numba, «Compiling Python code with @jit» (https://numba.readthedocs.io/en/stable/user/jit.html). Consultada el 23 de octubre de 2026. ↩

  5. Cython, «Typed Memoryviews» (https://cython.readthedocs.io/en/latest/src/userguide/memoryviews.html). Consultada el 23 de octubre de 2026. ↩

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

Respuesta plegada en cada pregunta

Autoevaluación

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

  2. 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) y df["maximo"].iloc[i - n:i] (3.068): cada barra paga dos o tres indexaciones de pandas
    • CDe np.array al final
    • DDe la lectura del Parquet
    Ver respuesta

    Respuesta B. De df.iloc[i] (5.000), df["ts"].iloc[i - 1] (4.970) y df["maximo"].iloc[i - n:i] (3.068): cada barra paga dos o tres indexaciones de pandas

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

  3. ¿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().

  4. ¿Qué ventaja tiene un perfilador de muestreo (pyinstrument, py-spy, profiling.sampling de 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.

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

Recursos