pineforge
CI / GitHub Actions

Гоните бэктесты PineScript в CI (GitHub Actions, GitLab, всё, где есть Docker)

Каждый коммит в репозиторий стратегии запускает бэктест. Parity проверяется, регрессии валят сборку. Тот же Docker-образ, что вы гоняете локально, исполняется в вашем runner'е.

Зачем parity в CI

Стратегии дрейфуют, как любой код. Переименование параметра, рефакторинг helper-функции, не замеченное изменение API в Pine v6 — любое из этого может тихо сместить сигналы, не сломав ни одного существующего теста. Без parity-гейта в CI коммит, который вы сделали два месяца назад, может изменить прошлонедельный список сделок — и узнаете вы об этом, только когда живой P&L посмотрит криво.

Дисциплина «коммитить эталонный список сделок рядом с исходником стратегии» — та же самая, что делает софт надёжным: вы описываете ожидаемый выход, и сборка падает, если фактический разойдётся. Для бэктеста ожидаемый выход — это список сделок: бар входа, бар выхода, направление, размер, цена fill'а — против запинненного исторического датасета.

Когда это сравнение бежит на каждом коммите, получается храповик: историческое поведение стратегии может только улучшаться, но не регрессировать случайно. Вы знаете exactly , какой коммит изменил выход — потому что сборка упала именно на нём. У вашей кривой капитала есть git blame.

Это важнее, чем большинство квантов осознаёт сразу. Зазор между «я оттестил эту стратегию» и «у меня есть воспроизводимая, версионированная запись каждого исторического сигнала, что эта стратегия когда-либо генерила» — огромный. Первое — скриншот. Второе — аудит-трейл. CI — это то, как вы поддерживаете второе без ручной работы на каждом push'е.

PineForge делает это возможным для Pine-стратегий, потому что runtime — это Docker-образ: бежит везде, где бежит Docker, выдаёт детерминированный выход на одинаковых входах и возвращает стабильную JSON-схему, безопасную для парсинга в shell-скриптах. Без браузера, без auth-флоу, без rate limit'ов на сам прогон бэктеста.

Пример GitHub Actions

Полный воркфлоу: чекаут стратегии, транспиляция в shared object через codegen API, прогон бэктеста на запинненном OHLCV CSV, парсинг JSON-отчёта через jq, сравнение с прокомиченным baseline и падение сборки, если delta net PnL превышает порог.

Добавьте API-ключ PineForge как repository secret с именем PINEFORGE_API_KEY, затем создайте .github/workflows/backtest.yml:

name: Backtest parity

on:
  push:
    branches: [main, "feat/**"]
  pull_request:

jobs:
  backtest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Transpile strategy to .so
        run: |
          curl -s https://api.pineforge.io/v1/codegen \
            -H "Authorization: Bearer ${{ secrets.PINEFORGE_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d '{"source": "'"$(cat strategy.pine | jq -Rs .)"'"}' \
            | jq -r '.artifact_url' \
            | xargs curl -sL -o strategy.so

      - name: Pull PineForge runtime
        run: docker pull ghcr.io/pineforge/runtime:latest

      - name: Run backtest
        run: |
          docker run --rm \
            -v "$PWD/strategy.so":/strategy.so \
            -v "$PWD/data/ohlcv.csv":/data.csv \
            ghcr.io/pineforge/runtime:latest \
            run /strategy.so --data /data.csv --output json \
            > report.json

      - name: Assert parity
        run: |
          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 awk "BEGIN{exit !($DELTA > $THRESHOLD)}"; then
            echo "Parity drift: net_pnl delta $DELTA exceeds threshold $THRESHOLD"
            diff <(jq '.trades' baseline/report.json) \
                 <(jq '.trades' report.json)
            exit 1
          fi
          echo "Parity OK — delta $DELTA (threshold $THRESHOLD)"

Храните baseline-отчёт по пути baseline/report.jsonв репозитории, прокомиченным рядом с исходником стратегии. Когда PR намеренно меняет поведение стратегии, автор обновляет baseline в составе того же PR. Этот diff становится постоянной записью того, что и почему изменилось.

Тот же воркфлоу одинаково бежит на GitLab CI, Bitbucket Pipelines или любом runner'е, где есть Docker — меняйте YAML-синтаксис, оставляйте shell-команды как есть.

Exit-коды и проверка parity

Runtime PineForge выходит с кодом 0при чистом прогоне и ненулевым на любой engine-ошибке (некорректный Pine, неподдерживаемый built-in, ошибка парсинга файла данных). CI-системы подхватывают эти exit-коды автоматически — никакой специальной обработки для движковых ошибок не нужно.

Проверка parity — отдельная задача, реализуется в вашем воркфлоу. JSON-схема отчёта стабильна между patch-версиями:

{
  "summary": {
    "total_trades": 47,
    "net_pnl": 12483.50,
    "max_drawdown": 3201.00,
    "profit_factor": 1.82,
    "sharpe_ratio": 1.34,
    "win_rate": 0.617
  },
  "trades": [
    {
      "entry_bar": 142,
      "exit_bar": 156,
      "direction": "long",
      "size": 1.0,
      "entry_price": 42310.5,
      "exit_price": 44820.0,
      "pnl": 2509.50,
      "exit_reason": "strategy.close"
    }
  ]
}

Для простой числовой проверки сравните summary.net_pnl и summary.total_trades с baseline. Для строгого сравнения сделка-в-сделку диффите весь массив trades — индексы баров входа и выхода, направления и цены fill'а должны совпадать. Когда не совпадают — diff показывает ровно, какая сделка разошлась, на каком баре и какой была разница в цене fill'а.

Практический порог для числовых полей: принимаете дельты с плавающей точкой до 0.01(копейка или базисный пункт на нормированной серии), валите всё, что больше. Для diff'ов сделка-в-сделку любое расхождение в entry_bar или exit_bar — всегда жёсткий fail, независимо от близости PnL: сделка на другом баре означает, что у вас изменилась логика сигнала.

Команды, внедряющие parity CI, обычно гоняют апдейт baseline отдельным шагом, гейченным по PR-метке. Так намеренное улучшение стратегии задокументировано в таймлайне PR — diff baseline всплывает в код-ревью рядом с тем Pine-изменением, что его вызвало.

Начать