---
title: "7.1.2 Rendimiento: profiling, Numba, Cython y Rust"
description: "Perfilar un backtest lento con cProfile, pyinstrument y py-spy, sacar a pandas del bucle y compilarlo con Numba y Cython; el muestreo de 3.15 y Rust."
url: "https://pythonparatrading.com/curso-python-trading/nivel-7/rendimiento-profiling-numba-cython-y-rust/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-7/rendimiento-profiling-numba-cython-y-rust.png"
---

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

- Curso: [Nivel 7: Producción, MLOps, regulación, carrera y capstone](https://pythonparatrading.com/curso-python-trading/nivel-7/)
- 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](https://pythonparatrading.com/curso-python-trading/nivel-0/concurrencia-threading-multiprocessing-asyncio/), [0.5.1 · NumPy 2: arrays, broadcasting, vectorización, álgebra lineal y generadores aleatorios](https://pythonparatrading.com/curso-python-trading/nivel-0/numpy-arrays-y-vectorizacion/), [0.5.2 · pandas 3.0: Series, DataFrame, índices temporales, Copy-on-Write, tipo str y pd.col()](https://pythonparatrading.com/curso-python-trading/nivel-0/pandas-3-series-y-dataframe/), [2.4.2 · Backtest por eventos desde cero: bucle, cartera, órdenes y fills](https://pythonparatrading.com/curso-python-trading/nivel-2/backtest-por-eventos-desde-cero/), [7.1.1 · Estructura de proyecto, configuración y calidad: del notebook al repositorio](https://pythonparatrading.com/curso-python-trading/nivel-7/estructura-de-proyecto-configuracion-y-calidad/)

## Al terminar esta lección

- Medir antes de optimizar y usar la ley de Amdahl para saber cuánto puede ganar, como máximo, acelerar una parte del programa.
- Leer un perfil determinista (cProfile) y uno de muestreo (pyinstrument, py-spy) y explicar en qué se diferencian, incluido el perfilador de muestreo de Python 3.15.
- Reescribir un bucle dependiente de la trayectoria sin objetos de pandas dentro y comprobar que da exactamente el mismo resultado.
- Compilar ese bucle con Numba y con Cython, leer la anotación de Cython y saber qué limita a cada herramienta.
- Situar Rust con PyO3 y maturin frente a Numba y Cython: qué ofrece y qué cuesta.

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

```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 $p$ del tiempo y se acelera $s$ veces, el programa entero se acelera

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

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

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:

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

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

Todas las cifras de tiempo de esta lección (segundos, milisegundos, veces más rápido) son de la máquina en la que se
escribió, medidas con `timeit` tomando el mejor de varios intentos, y van en el texto, no en las salidas. En otra
máquina cambian las cifras; los órdenes de magnitud y las comprobaciones de que los resultados son idénticos, no.

</aside>

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

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

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

```python skip
print(pf.output_text(unicode=True, color=False))
```
```output
  _     ._   __/__   _ _  _  _ _/_   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>`):

```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])
```
```output
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 2026[^1]) reorganiza los perfiladores en un paquete nuevo, `profiling`
(PEP 799[^2]): `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érprete[^3]. 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:

```python skip
# 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:

```mermaid
flowchart LR
  P[preparar: pandas] --> A[arrays de NumPy]
  A --> B[bucle compilable]
  B --> S[posiciones]
```

```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)
```
```output
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 argumentos[^4]. 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:

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

```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])
```
```output
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 puro[^5]. 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):

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

```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)
```
```output
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 ruedas[^6]. 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):

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

```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))
    }
}
```

```python skip
# 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:

```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)
```
```output
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.* Tres curvas con el eje horizontal logarítmico de 1 a 1.000. Con p = 0,5 la curva se aplana enseguida cerca de 2. Con p = 0,9 sube hasta casi 10; el punto destacado, s = 100, da S = 9,2. Con p = 0,99 sube hasta unas 91 veces con s = 1.000.

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

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

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

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

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

[^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.

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

   - A. 100 veces
   - B. 90 veces
   - C. Unas 9,2 veces: el 10 % que no se tocó pasa a dominar
   - D. Unas 50 veces

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

   - A. Son un artefacto del perfilador
   - 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
   - C. De `np.array` al final
   - D. De la lectura del Parquet

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

   - 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
   - B. Porque Numba solo funciona en GPU
   - C. Porque Numba no admite bucles
   - D. Porque el DataFrame es demasiado grande

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

   - A. Da el número exacto de llamadas
   - B. Es más preciso en funciones muy cortas
   - C. Solo funciona con Python 3.15
   - 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

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

   - A. Reescribir el bucle en Rust
   - B. Compilar también el bucle con Cython
   - 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
   - D. Aumentar el número de hilos de Numba

   *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

### Documentación

- [Python 3.15: profiling.sampling, perfilador estadístico (Tachyon)](https://docs.python.org/3.15/library/profiling.sampling.html) · docs.python.org
- [PEP 799: un paquete `profiling` para los perfiladores de Python](https://peps.python.org/pep-0799/) · peps.python.org
- [Numba: compilar código Python con @jit](https://numba.readthedocs.io/en/stable/user/jit.html) · numba.readthedocs.io
- [Cython: typed memoryviews](https://cython.readthedocs.io/en/latest/src/userguide/memoryviews.html) · cython.readthedocs.io
- [py-spy: perfilador de muestreo para Python](https://github.com/benfred/py-spy) · github.com
- [PyO3: guía](https://pyo3.rs/) · pyo3.rs

### Datos

- [Datos del nivel 5: barras de un minuto de un futuro (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/hf5_barras_1min.parquet) · Descarga · hf5_barras_1min.parquet

Lección anterior: [7.1.1 · Estructura de proyecto, configuración y calidad: del notebook al repositorio](https://pythonparatrading.com/curso-python-trading/nivel-7/estructura-de-proyecto-configuracion-y-calidad/)

Lección siguiente: [7.1.3 · Patrones: eventos, colas de mensajes y arquitectura hexagonal](https://pythonparatrading.com/curso-python-trading/nivel-7/patrones-event-driven-colas-y-arquitectura-hexagonal/)
