11 de septiembre de 2026 · reproducibilidad

Mismo código, mismos datos, 2 Sharpe distintos: la auditoría de no determinismo que necesita tu backtest

Mismo código, mismos datos, 2 Sharpe distintos: la auditoría de no determinismo que necesita tu backtest

Mismo commit, mismos archivos parquet, mismo equipo, 2 ejecuciones con una hora de diferencia. Sharpe 1.34 y Sharpe 1.19. Rentabilidad total 41.2% y 37.8%. Número de operaciones: 1,418 y 1,421. Y las 2 curvas de capital coincidieron hasta el último decimal mostrado durante 11,205 barras consecutivas antes de separarse.

0.15de diferencia de Sharpe, con entradas idénticas
3 / 1,418operaciones distintas
11,205barras idénticas antes de separarse

Los 3 primeros números son frustrantes. El último es el interesante, porque indica que el problema no es un error de coma flotante impreciso repartido por toda la ejecución. Ocurrió algo discreto en una barra concreta, y todo lo demás fue amplificación acumulada. Esta publicación trata de encontrar esa barra y de lo que ese 0.15 implica para cada barrido de parámetros que hayas ejecutado.

La estrategia: momentum transversal en los 30 perps USDⓈ-M de mayor volumen, rebalanceo cada 4 horas, posiciones largas en los 5 primeros según la rentabilidad de 12 horas y cortas en los 5 últimos, 18 meses de historial, comisiones y financiación cobradas por tramo.

Encontrar la barra en la que divergen las ejecuciones

Si registras el capital por barra con precisión completa, esto lleva unos 4 minutos. Vuelca ambas ejecuciones a un CSV de (bar_index, equity, open_positions_hash), carga los 2 archivos y encuentra el primer índice en que discrepan. Ya habíamos escrito ese script para otro error; es la única razón por la que no pasé una mañana entera con esto.

Barra 11,206, 2025-03-14T08:00 UTC. El capital era idéntico en la barra anterior hasta 13 cifras significativas. En la barra 11,206 los hashes de posición difieren: la ejecución A está larga en SOL; la ejecución B, larga en AVAX. Mismo lado, mismo nocional, distinto símbolo. Todo lo que viene después depende de eso.

Así que imprimí las entradas de la clasificación para esa barra en ambas ejecuciones. Eran idénticas. Idénticas byte por byte: los mismos 30 símbolos y las mismas 30 puntuaciones. Dos de las puntuaciones eran 0.0. Exactamente cero, ambas, porque los dos activos habían registrado una barra de 4 horas sin operaciones dentro de la ventana de observación: el cociente entre cierres dio uno y el logaritmo dio cero. No era un redondeo a cero. Era cero.

Dos símbolos empataron en el puesto cinco. La selección tomaba los 5 primeros. Cuál de los 2 entraba dependía del orden que les dejara la ordenación, y esta no hacía lo que yo suponía.

Un empate, una ordenación inestable y eso que llevaba 2 años pasando por alto

La clasificación pasaba por una agregación agrupada, y el marco de datos que la alimentaba venía de una comprensión de diccionario sobre un conjunto de símbolos que se reconstruía en cada barra a partir de una consulta asíncrona. El orden de iteración del conjunto cambia según la semilla hash, y Python aleatoriza la semilla hash de cadenas en cada proceso, a menos que fijes PYTHONHASHSEED. Así que el orden de las filas antes de ordenar difería entre ejecuciones y, con una ordenación no estable sobre una clave empatada, el desempate cambiaba.

Empates como este no son casos rarísimos. Son estructurales. Siempre que una característica se satura o se recorta, se crean igualdades exactas: las barras sin volumen dan rentabilidades exactamente iguales a cero, una puntuación z recortada queda fijada en ±3.0, una transformación de rangos con pocos valores distintos produce decenas de empates y un filtro booleano asigna 1.0 a todo lo que lo supera. En 18 meses de barras de 4 horas, esta ejecución tuvo 47 barras con un empate en el límite de selección. En 3 de ellas cambió la cesta seleccionada. En las demás, el empate fue entre 2 activos que ya estaban dentro o que ya estaban fuera.

Durante más o menos un día estuve convencido de que el cargador de datos era no determinista, porque esa era la respuesta emocionante. No lo era. Nunca lo es. Era un conjunto, un empate y una suposición sobre la estabilidad de la ordenación que nadie había documentado.

Por qué 3 operaciones valen 0.15 de Sharpe

Esta es la parte que suele generar objeciones, y la respuesta es que un backtest con dimensionamiento como fracción del capital es un sistema dependiente de la trayectoria. Dimensionar cada tramo al 8% del capital actual significa que una diferencia de capital en la barra n implica una diferencia en cada nocional desde la barra n en adelante.

La primera divergencia tuvo muy poco efecto por sí sola. El tramo de AVAX de la ejecución B perdió 2.1% en 9 horas; el tramo de SOL de la ejecución A ganó 0.4%. Diferencia de capital tras esa operación: 0.21%. Insignificante. Pero a partir de ahí las 2 ejecuciones ya no siguen la misma estrategia. Mantienen posiciones de tamaños ligeramente distintos, así que acumulan importes de financiación ligeramente distintos, y 2 empates de límite posteriores también se resolvieron de forma distinta porque las puntuaciones que los alimentaban ya procedían de posiciones mantenidas marginalmente diferentes. Uno ocurrió el 2025-03-27, un día antes de una tendencia de 6 días que generó aproximadamente un tercio del PnL total de la ejecución. La ejecución A estuvo posicionada durante todo el movimiento; la ejecución B entró un rebalanceo más tarde.

Diferencia de rentabilidad: 3.4 puntos. La diferencia de Sharpe es mayor de lo que sugiere la diferencia de rentabilidad porque las operaciones reordenadas de la ejecución B coincidieron con un tramo más volátil, así que aumentó el denominador mientras caía el numerador. Una causa pequeña, 2 amplificadores.

Si tu dimensionamiento usa un nocional fijo y tus entradas no dependen de las posiciones actuales, estás mucho más protegido. La mayoría de las estrategias interesantes no cumplen ninguna de esas condiciones.

Los 5 puntos donde esto aparece realmente

OrigenSíntomaSolución
PYTHONHASHSEED sin fijar, con el orden de iteración de conjuntos/diccionarios alimentando una ordenaciónLos desempates cambian entre ejecuciones; la primera divergencia aparece en una barra concretaFija la semilla; ordena con una clave secundaria explícita (el símbolo) para que los empates se resuelvan de forma determinista
Ordenación no estable con una clave empatada (quicksort predeterminado en NumPy/pandas)Lo mismo que arriba; persiste incluso al fijar la semillakind="stable", o convierte la clave en un orden total
Generador de números aleatorios sin semilla en bootstrap, mezclas de entrenamiento/prueba o variación sintética de ejecucionesDeriva en toda la ejecución, sin un punto claro de divergenciaUna semilla explícita por componente, registrada en el manifiesto de ejecución
Reducción paralela de flotantes (orden de suma dependiente del número de hilos)Diferencias en los últimos bits, normalmente inocuas hasta que cruzan un umbral de comparaciónFija el número de hilos en las ejecuciones de investigación; nunca compares flotantes con == en un límite de decisión
Versiones de bibliotecas sin fijarReproducible hoy, pero no en noviembreHash del archivo de bloqueo en el manifiesto junto al hash de la instantánea de datos

La cuarta fila importa menos de lo que la gente teme, y la primera es la que causa problemas constantemente.

La reproducibilidad bit a bit es una herramienta, no una virtud

Quieres determinismo para que, al cambiar una línea, la diferencia en la curva de capital pueda atribuirse a esa línea. Esa es la única razón. Ahora, cada ejecución de agente en Stratmill escribe un manifiesto con el hash de la instantánea de datos, el hash del archivo de bloqueo y cada semilla; si una repetición no reproduce la curva anterior bit a bit, es una compilación fallida, no una curiosidad.

Pero una vez que puedas reproducirlo, rómpelo a propósito. Ejecuta el sistema 64 veces con 64 semillas y observa la dispersión:

La banda de variación. La misma estrategia, los mismos datos y 64 permutaciones con semillas distintas para resolver empates y ordenar ejecuciones. Sharpe p5 1.12, mediana 1.27, p95 1.41. Ancho de banda: 0.29.

Ahora vuelve al barrido de parámetros. La mejor configuración obtuvo 1.46. La configuración en el puesto 40 de 96 obtuvo 1.31. La diferencia entre ellas es 0.15, la mitad de la banda. El barrido no clasificó esas 2 configuraciones. Tomó una muestra de cada una de sus distribuciones y ordenó esas muestras.

Ese cambio de perspectiva modificó nuestra forma de elegir. El resultado de un barrido solo es una clasificación si las diferencias entre configuraciones superan la variación de una sola configuración; y, en una estrategia dependiente de la trayectoria con 1,400 operaciones, la variación suele ser suficientemente grande como para convertir el tercio superior de la clasificación en un empate. Cuando eso pasa, elige según algo que la banda no pueda ocultar: menor rotación, menos parámetros, una hipótesis de costes que puedas defender ante un escéptico, mejor comportamiento en el tramo de validación walk-forward que menos te gusta. Esos sí son criterios reales para desempatar. Una ventaja de 0.15 de Sharpe no lo es.

Una cosa más que conviene hacer antes de fiarte de todo esto. Ejecuta ahora mismo tu backtest 2 veces, compara el capital por barra y averigua si estás en el grupo de resultados idénticos bit a bit o en el de 0.15. Es un experimento de 15 minutos que te dice cuánto de tu historial de investigación medía la estrategia y cuánto medía una semilla hash.

determinismobacktestingingeniería de datossobreajustepython
← Todos los artículos