PineForge backtestea tu Pine Script en CI; si las operaciones se desvían, falla la compilación.
Por qué paridad en CI
Las estrategias derivan como cualquier código. Renombrar un parámetro, refactorizar una función auxiliar, un cambio en la API de Pine v6 que pasó desapercibido: cualquiera puede desplazar señales en silencio sin romper pruebas. Sin una puerta de paridad en CI, un commit antiguo puede cambiar la lista de operaciones de la semana pasada y solo lo notas cuando el PnL en vivo «huele» mal.
Versionar junto al Pine tu lista de operaciones de referencia es la misma disciplina que hace fiable el software: defines la salida esperada y la compilación falla si la salida real diverge. En un backtest, la salida esperada es la lista de operaciones (barra de entrada y de salida, dirección, tamaño y precio de ejecución) sobre un histórico fijado.
Si esa comparación corre en cada commit, tienes un trinquete: el comportamiento histórico solo mejora, no regresa por accidente. Sabes exactamente qué commit cambió la salida, porque la compilación falló en ese punto. Puedes usar git blame sobre tu curva de equity.
Esto pesa más de lo que muchos quants creen al principio. La brecha entre «probé esta estrategia» y «tengo un registro versionado de cada señal histórica» es enorme. Lo primero es una captura de pantalla; lo segundo es una pista de auditoría. La CI mantiene lo segundo sin esfuerzo manual en cada push.
PineForge encaja aquí porque el runtime es una imagen Docker: funciona allí donde funcione Docker, da las mismas operaciones con los mismos datos de entrada y escribe un informe JSON que puedes analizar con jq. No hay navegador, ni inicio de sesión, ni límite de frecuencia en el propio backtest.
Ejemplo GitHub Actions
El flujo completo: obtén una copia de tu estrategia, transpílala a C++ con la imagen pineforge-release, ejecuta el backtest con tu CSV OHLCV fijado y analiza el informe JSON con jq, compáralo con la línea base incluida en el repositorio y haz fallar la compilación si una operación cambia de dirección o de barra, o si el delta del PnL neto supera tu umbral.
El runtime es una imagen de contenedor, así que no hay ninguna clave de API que gestionar. Basta con un docker run en el job. Crea .github/workflows/backtest.yml:
name: Backtest parity
on:
push:
branches: [main, "feat/**"]
pull_request:
jobs:
backtest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Pin the image version so results only change when you change it.
- name: Transpile strategy to C++
run: |
docker run --rm --network=none \
-e PINEFORGE_TRANSPILE_ONLY=1 \
-v "$PWD/strategy.pine":/in/strategy.pine:ro \
ghcr.io/pineforge-4pass/pineforge-release:1.10.0 > strategy.cpp
- name: Run backtest
run: |
docker run --rm --network=none \
-v "$PWD/strategy.cpp":/in/strategy.cpp:ro \
-v "$PWD/data/ohlcv.csv":/in/ohlcv.csv:ro \
ghcr.io/pineforge-4pass/pineforge-release:1.10.0 \
> report.json
- name: Assert parity
run: |
# Every trade keeps its side and its entry and exit bars,
# and net P&L moves by at most THRESHOLD.
BARS='[.trades[] | [.side, .entry_bar_index, .exit_bar_index]]'
ACTUAL=$(jq '.summary.net_pnl' report.json)
BASELINE=$(jq '.summary.net_pnl' baseline/report.json)
DELTA=$(echo "$ACTUAL $BASELINE" | awk '{d=$1-$2; print (d<0)?-d:d}')
THRESHOLD="0.01"
if [ "$(jq -c "$BARS" report.json)" != "$(jq -c "$BARS" baseline/report.json)" ]; then
echo "Parity drift: a trade changed side, entry bar or exit bar"
elif awk "BEGIN{exit !($DELTA > $THRESHOLD)}"; then
echo "Parity drift: net_pnl delta $DELTA exceeds threshold $THRESHOLD"
else
echo "Parity OK - same sides and bars, net_pnl delta $DELTA (threshold $THRESHOLD)"
exit 0
fi
# One trade per line, so diff's line numbers are trade numbers.
TRADE='.trades[] | {side, entry_bar_index, exit_bar_index, entry_price, exit_price, qty, pnl}'
diff <(jq -c "$TRADE" baseline/report.json) <(jq -c "$TRADE" report.json)
exit 1Guarda la línea base en baseline/report.json en el repositorio, versionada junto al Pine. Si un PR cambia el comportamiento de forma deliberada, quien lo abre actualiza la línea base en el mismo PR. Ese diff queda como registro permanente del qué y el porqué.
El mismo flujo funciona igual en GitLab CI, Bitbucket Pipelines o cualquier runner con Docker: cambia la sintaxis YAML y conserva los comandos de shell.
Códigos de salida y comprobación de paridad
El runtime PineForge sale con código 0 en una ejecución limpia y un código distinto de cero si algo falla: 2 si falta un archivo de entrada, 3 si falla la compilación, 4 si falla el backtest y 5 si falla la transpilación (Pine no admitido o un error de sintaxis). La CI los detecta sin ningún tratamiento especial.
Comprobar el resultado depende de tu flujo de trabajo. El informe tiene este aspecto (recortado):
{
"engine": "pineforge",
...
"summary": {
"total_trades": 3483,
"wins": 1288,
"losses": 2194,
"win_rate_pct": 36.9796,
"net_pnl": 2518.39,
"max_drawdown": -1382.76,
...
},
"trades": [
{
"n": 1,
"side": "long",
"entry_time": 1729402200000,
"exit_time": 1729422000000,
"entry_price": 2640.96,
"exit_price": 2645.15,
"qty": 1.0,
"pnl": 4.19,
"entry_bar_index": 34,
"exit_bar_index": 56,
...
}
],
"metrics": { "all": { "profit_factor": 1.06, ... }, "equity": { "sharpe_tv": ..., ... } },
"equity_curve": [ ... ],
"fingerprint": { "digest": "sha256:...", ... }
}Para una comprobación numérica sencilla, compara summary.net_pnl y summary.total_trades contra la línea base. El flujo de arriba también lee el array trades: cada operación debe conservar su dirección y sus barras de entrada y de salida. Si una comprobación falla, el flujo compara con diff las dos listas de operaciones, una operación por línea, así que los números de línea de diff coinciden con los números de operación y cada línea muestra la dirección, las barras, los precios, la cantidad y el P&L de una operación, antes y después.
Umbral práctico en campos numéricos: acepta diferencias en coma flotante hasta 0.01 (un centavo de P&L neto) y haz fallar la compilación si se supera. Para las operaciones no hay tolerancia: el flujo de arriba falla ante cualquier cambio de dirección, de entry_bar_index o exit_bar_index, sea cual sea el P&L. Si una operación cae en otra barra, es que cambió la lógica de la señal.
Un patrón práctico: actualiza la línea base en un paso aparte del flujo de trabajo, condicionado a una etiqueta del PR, para que un cambio intencionado aparezca en la revisión de código junto al cambio en Pine que lo causó.