Backtest reproducible en Python sin API key
Ejecuta un backtest reproducible en Python sin API key: instalación fijada, datos de muestra deterministas y una lectura honesta de lo que oculta una curva de capital.
Un backtest reproducible en Python permite que otra persona lo ejecute en una máquina limpia y obtenga exactamente tus mismos resultados, sin que una cuenta ni una API key se interpongan. La mayoría de los tutoriales no supera esa prueba desde la primera línea: una descarga en tiempo real entrega al siguiente lector un historial de precios ligeramente distinto del que recibió el autor. Esta guía fija un motor de código abierto, quantjourney-bt en la versión 0.12.4, ejecuta su ejemplo incluido sin credenciales y después utiliza datos reales de mercado para mostrar qué no puede decirte una sola ejecución limpia.
¿Qué hace reproducible un backtest?
Aquí, reproducibilidad tiene un significado concreto y comprobable: una segunda persona ejecuta un comando en una máquina limpia y obtiene exactamente tus cifras, hasta el último decimal. Dos factores habituales lo impiden.
El primero es el código. Una biblioteca en la versión 0.x no ofrece garantías de compatibilidad entre versiones menores. Un cambio de nombre, una configuración predeterminada distinta o una columna reordenada pueden hacer que el script siga ejecutándose, pero que informe silenciosamente algo diferente.
El segundo son los datos. Un tutorial cuya primera línea descarga datos en tiempo real nunca fue reproducible. Los proveedores revisan el historial, ajustan los datos por splits y completan brechas. Por eso, el mismo script puede mostrar cifras distintas un mes después. A posteriori, no puedes separar un cambio en el código de un cambio en los datos.
El patrón de este proyecto que vale la pena replicar consiste en combinar una versión fija del motor con un conjunto de datos pequeño e incluido en el propio paquete.
Fija la versión: pip install quantjourney-bt==0.12.4
quantjourney-bt es el backtester QuantJourney, publicado bajo la licencia Apache 2.0 y compatible con Python 3.11 o posterior. La versión 0.12.4 se publicó el 21 de julio de 2026. Es la versión a la que hacen referencia todos los comandos siguientes, según la documentación de agosto de 2026.
Trabaja dentro de un entorno aislado. python3 -m venv .venv crea uno, source .venv/bin/activate lo activa y python -m pip install -U pip actualiza el instalador dentro de ese entorno. Después, fija la versión exacta: pip install quantjourney-bt==0.12.4.
El proyecto documenta la forma sin fijar, pip install quantjourney-bt. La parte ==0.12.4 queda bajo tu responsabilidad y, en la serie 0.x, resulta especialmente importante. Deja registrada la versión fijada donde la encuentre la siguiente persona: pip freeze > requirements.txt captura todas las dependencias resueltas, incluidas las que nunca especificaste.
Hay dos extras opcionales. pip install "quantjourney-bt[wf]" agrega Optuna para los ejemplos de walk-forward y optimización. pip install "quantjourney-bt[data]" agrega un fallback de yfinance utilizado para los benchmarks.
Apache-2.0 es una licencia permisiva. Puedes usar y modificar el código con fines comerciales. Debes conservar los archivos de licencia y avisos al redistribuirlo. Además, los contribuidores otorgan explícitamente derechos sobre las patentes.
Cómo ejecutar el ejemplo incluido de SMA sin una clave de API
El repositorio incluye un script de lanzamiento junto con cincuenta estrategias de ejemplo ejecutables. Están divididas entre una ruta basada en pesos y otra basada en órdenes. Entre ellas hay cinco flujos de trabajo walk-forward. ./strategy.sh --list muestra el catálogo. ./strategy.sh example_weights_01_sma_daily --check importa una sola estrategia y no toca ningún dato. Es la confirmación más rápida de que la instalación funciona correctamente.
La ejecución de demostración ocupa una sola línea: ./strategy.sh example_weights_01_sma_daily --sample-data --output /tmp/qj-sample
La opción --sample-data concentra todo el propósito. El proyecto describe así el dataset que utiliza:
El dataset de muestra es deliberadamente pequeño y reproducible. Es útil para comprobar la instalación, generar informes y revisar el flujo del motor sin crear una cuenta.
Fuente: README de quantjourney-bt, versión 0.12.4, consultado el 6 de agosto de 2026.
La ejecución genera un directorio, no un resultado en la consola: summary.txt y summary.json, un metrics.csv, un equity_curve.csv junto con su equity_curve.png, un dashboard.html, una carpeta plots/ y un run_metadata.json que registra cómo se configuró la ejecución. Este último archivo es el que más personas omiten y el que permite auditar un resultado un año después.
Interpreta las métricas resultantes con rigor. El dataset incluido es pequeño e ilustrativo. Por eso, el ratio de Sharpe y el máximo drawdown que aparecen en summary.txt describen un archivo de muestra. No son evidencia sobre una estrategia. Tratarlos como un resultado es el primer error que puede cometerse.
La ejecución sí confirma algo importante: la instalación funciona y todo el flujo del motor —desde la señal hasta los pesos objetivo y la reconstrucción del valor de la cartera— genera sus artefactos en tu equipo sin una sola credencial. Existe una ruta con credenciales al servicio de datos propio del proyecto para realizar backtests con históricos reales. Esa ruta está documentada. Este recorrido termina aquí, en la parte que no necesita nada de terceros.
Lo que una curva de capital dentro de muestra no revela
La ejecución de la muestra genera una curva de capital. Esto es lo que esa curva no puede demostrar, medido con datos reales de mercado en lugar de un archivo de demostración.
El panel siguiente toma la misma idea que utiliza la estrategia de ejemplo: una media móvil de 20 sesiones que cruza una de 50 sesiones. La aplica a SPY y muestra cada año calendario por separado, de 2017 a 2025. La posición de cada sesión queda determinada por el cierre de la sesión anterior. Por tanto, la regla nunca opera con un dato que aún no estaba disponible.
El SQL exacto detrás de cada cifra
WITH daily AS
(
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
toFloat64(argMax(close, window_start)) AS px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= '2016-01-01 00:00:00'
AND window_start < '2026-01-01 05:00:00'
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
GROUP BY d
),
averaged AS
(
SELECT
d,
px,
avg(px) OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
avg(px) OVER (ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
row_number() OVER (ORDER BY d) AS session_no
FROM daily
),
positioned AS
(
SELECT
d,
px,
if(session_no >= 50 AND fast_ma > slow_ma, 1, 0) AS long_today,
lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
lagInFrame(px, 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
FROM averaged
)
SELECT
toYear(d) AS year,
round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS rule_pct,
round((exp(sum(log(px / px_prior))) - 1) * 100, 1) AS hold_pct,
countIf(long_today != long_prior) AS crossover_count
FROM positioned
WHERE px_prior > 0
AND toYear(d) >= 2017
GROUP BY year
ORDER BY yearUna sola regla sin cambios, medida 9 veces por separado. Lea primero las dos columnas de porcentajes. En 2017, la regla terminó el año en 16%, frente a 19.4% de mantener SPY durante el mismo periodo. En 2025, esas mismas dos columnas muestran 10.4% y 16.4%. El código es idéntico en ambas filas. Solo cambió la ventana.
La columna de cruces muestra lo limitada que es la evidencia subyacente. 4 cambios de posición en 2025 implican que un año completo de curva de capital depende de un puñado de decisiones. Es una muestra demasiado pequeña para considerarla un resultado.
¿La misma regla se comporta igual con otros valores?
Cambiar la ventana de fechas es una forma de analizar una sola curva. Cambiar el universo es la otra. El panel siguiente mantiene fijos los parámetros y aplica la misma regla a cinco valores líquidos durante los cinco años calendario, de 2021 a 2025.
El SQL exacto detrás de cada cifra
WITH daily AS
(
SELECT
ticker,
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
toFloat64(argMax(close, window_start)) AS px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('SPY', 'QQQ', 'AAPL', 'MSFT', 'KO')
AND window_start >= '2020-07-01 00:00:00'
AND window_start < '2026-01-01 05:00:00'
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
GROUP BY ticker, d
),
averaged AS
(
SELECT
ticker,
d,
px,
avg(px) OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
avg(px) OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
row_number() OVER (PARTITION BY ticker ORDER BY d) AS session_no
FROM daily
),
positioned AS
(
SELECT
ticker,
d,
px,
lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
lagInFrame(px, 1)
OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
FROM averaged
)
SELECT
ticker AS symbol,
round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS rule_pct,
round((exp(sum(log(px / px_prior))) - 1) * 100, 1) AS hold_pct,
round(avg(long_prior) * 100, 0) AS days_long_pct
FROM positioned
WHERE px_prior > 0
AND d >= toDate('2021-01-01')
GROUP BY symbol
ORDER BY rule_pct DESCQQQ se sitúa en la parte superior del panel, con 40.2%, mientras que la fila inferior, KO, registra 3.8%. La columna days_long_pct indica qué proporción de la ventana pasó cada versión manteniendo alguna posición, 67% en el caso de la fila superior. Un conjunto de parámetros, cinco universos y un spread lo bastante amplio como para que elegir al ganador a posteriori no diga nada sobre la operación que todavía no se ha realizado.
Nada de esto constituye una recomendación para operar con un crossover. El crossover es una referencia para medir el backtest, y el backtest es lo que estamos midiendo.
Dónde aparece el sesgo de anticipación en un backtest basado en ponderaciones
Un motor basado en ponderaciones convierte una señal en ponderaciones objetivo, simula las ejecuciones frente a esas ponderaciones y luego reconstruye el valor de la cartera a partir de las posiciones resultantes. El problema se oculta en la conexión entre la señal y la ponderación. Si la ponderación de hoy se calcula con el cierre de hoy y después genera el rendimiento de hoy, el backtest ha operado con información que no existía cuando se habría enviado la orden. Eso es sesgo de anticipación, y no genera ningún error. Simplemente hace que todo parezca mejor.
El panel siguiente ejecuta ambas versiones de una misma regla sobre el mismo historial de SPY.
El SQL exacto detrás de cada cifra
WITH daily AS
(
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
toFloat64(argMax(close, window_start)) AS px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= '2016-01-01 00:00:00'
AND window_start < '2026-01-01 05:00:00'
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
GROUP BY d
),
averaged AS
(
SELECT
d,
px,
avg(px) OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
avg(px) OVER (ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
row_number() OVER (ORDER BY d) AS session_no
FROM daily
),
positioned AS
(
SELECT
d,
px,
if(session_no >= 50 AND fast_ma > slow_ma, 1, 0) AS long_today,
lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
lagInFrame(px, 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
FROM averaged
)
SELECT
toYear(d) AS year,
round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS next_bar_pct,
round((exp(sum(log(if(long_today = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS same_bar_pct,
round(abs(exp(sum(log(if(long_today = 1, px / px_prior, 1.0))))
- exp(sum(log(if(long_prior = 1, px / px_prior, 1.0))))) * 100, 1) AS gap_pp
FROM positioned
WHERE px_prior > 0
AND toYear(d) >= 2017
GROUP BY year
ORDER BY yearEn 2017, la versión basada en la sesión anterior mostró 16%, mientras que la versión basada en la misma sesión mostró 17.1%: una diferencia de 1.1 puntos porcentuales. En 2025, la distancia entre ambas midió 1.6 puntos porcentuales. Solo una de esas columnas puede haber sido producida por una máquina que no supiera de antemano dónde cerró la sesión. La diferencia entre ambas es pura contabilidad: no hay una idea, una habilidad ni una operación detrás.
Este motor expresa su propia postura sobre la cuestión del momento de ejecución. Eso vale más que una promesa:
Para las ejecuciones en la apertura, el deslizamiento sensible al rango solo utiliza la barra completada anterior, y la capacidad de volumen se pronostica a partir de observaciones rezagadas; el motor no utiliza el máximo, el mínimo, el cierre ni el volumen de toda la sesión de ese día.
Fuente: README de quantjourney-bt, versión 0.12.4, consultado el 6 de agosto de 2026.
Un supuesto documentado puede contrastarse con el código fuente que ya instalaste. Uno no documentado es una suposición.
Por qué existe el extra de walk-forward
Los ejemplos de walk-forward, WF01 a WF05, incluyen el extra [wf] y su dependencia de Optuna. El ajuste walk-forward estima parámetros con un tramo de la historia y los evalúa en el tramo siguiente. Después desplaza ambos tramos hacia adelante y repite el proceso. Las variantes rolling y expanding se diferencian en si la ventana de ajuste elimina los datos más antiguos a medida que avanza. Otro ejemplo añade un purge y un embargo en cada límite. Elimina las observaciones más cercanas a la separación para evitar que el tramo usado en el ajuste contamine el tramo que se está evaluando.
Nada de esto convierte una idea débil en una estrategia operativa. Sustituye un único número por una distribución de resultados que se puede analizar y cuestionar. Esa es toda la mejora. El paso siguiente todavía no implica dinero real: hacer paper trading antes de operar con dinero real mide lo que un backtest no puede detectar por su propia estructura. Para empezar, muestra si las órdenes se ejecutan cerca del precio supuesto por el simulador. Además, usar circuit breakers para bots de trading cubre lo que hace el código el día en que algo falla. Para consultar las estadísticas que sustentan todo el proceso, nuestras notas sobre el libro de trading cuantitativo de código abierto ofrecen un nivel más profundo.
Preguntas frecuentes
¿Se puede hacer backtesting de una estrategia sin una clave de API?
Sí. quantjourney-bt incluye un dataset de muestra detrás del indicador --sample-data, y sus estrategias de ejemplo se ejecutan con él sin cuenta ni credenciales. El dataset es pequeño y sirve como ilustración, así que conviene considerar esa ejecución como una comprobación de la instalación y del pipeline, no como evidencia sobre una estrategia.
¿Por qué fijar la versión de un paquete de backtesting para Python?
Un paquete en la serie 0.x no ofrece garantías de compatibilidad entre versiones menores, y un cambio en el valor predeterminado o en el nombre de una métrica puede pasar inadvertido. Fijar la versión con pip install quantjourney-bt==0.12.4 y registrar el entorno en un archivo de requirements permite reconstruir el próximo año un resultado generado hoy con el mismo motor que lo produjo.
¿Con qué licencia se distribuye quantjourney-bt?
Apache License 2.0. Permite el uso comercial y la modificación. Exige conservar los archivos de licencia y avisos en cualquier redistribución, e incluye una concesión explícita de patentes por parte de los contribuidores. La versión 0.12.4 se publicó el 21 de julio de 2026 y requiere Python 3.11 o posterior.
¿Un resultado sólido de backtesting significa que la estrategia funciona?
No. Un backtest es una medición realizada sobre una ventana, en un universo. Los paneles anteriores muestran que una misma regla, sin cambios, produce cifras anuales muy distintas en un ticker y resultados muy diferentes entre cinco nombres. Esa es precisamente la diferencia que la validación walk-forward y las pruebas out-of-sample buscan revelar.
Cómo se calcularon los paneles anteriores
Los cierres diarios corresponden al último print de la sesión regular de cada fecha, tomado según el horario de Nueva York entre las 9:30 a. m. y las 4:00 p. m. Esto mantiene correctos los días de media sesión con cierre anticipado sin fijar de antemano la duración de la sesión. La media rápida cubre 20 sesiones y la lenta, 50; ambas son simples. Las primeras 49 sesiones de cada serie son un periodo de calentamiento en el que no se mantiene ninguna posición. Las cifras anuales capitalizan el movimiento de cierre a cierre de cada sesión durante las sesiones en las que la regla mantuvo una posición larga. La columna de mantener la posición capitaliza, para comparar, todas las sesiones del mismo año. Los cinco nombres de la comparación transversal se eligieron por tener historiales continuos y no presentar ningún split dentro de la ventana, por lo que la serie de cierres no requiere ajustes. Las ventanas están fijadas en el pasado, así que estos paneles devuelven las mismas cifras en cada regeneración.
Cada panel incluye debajo el SQL exacto con el que se calculó. Por eso, las cifras de esta página se pueden volver a ejecutar, igual que la instalación con la versión fijada. Para medir una regla en tu propia ventana antes de escribir código de backtesting, formula la pregunta en inglés sencillo en la terminal de Strasmore.