PineForgeがCIでPine Scriptをバックテスト。取引がずれたらCI失敗。
なぜCIでパリティを担保するか
戦略も、ほかのコードと同じようにドリフトします。パラメータのリネーム、ヘルパー関数のリファクタリング、見落としていたPine v6のAPI変更。どれも既存のテストを壊すことなく、シグナルを知らないうちにずらす可能性があります。CIにパリティチェックがなければ、2か月前のコミットが先週の取引一覧を変えてしまっても、ライブ運用の損益がおかしくなるまで気づけません。
リファレンスとなる取引一覧を戦略のソースと一緒にコミットするのは、ソフトウェアの信頼性を支えているのと同じ規律です。期待する出力を記述しておき、実際の出力がそれと食い違えばビルドを失敗させます。バックテストの場合、期待する出力とは、固定した過去データセットに対する取引一覧(エントリーバー、決済バー、売買方向、サイズ、約定価格)です。
その比較がコミットのたびに実行されると、ラチェットが得られる:戦略の過去の動作は改善されることはあっても、不意にリグレッションすることはない。出力を変えたのがどのコミットかは 正確に わかる。なぜならそのコミットでビルドが失敗したからだ。エクイティカーブに対してgit blameができる。
これはほとんどのクオンツが最初に思うよりずっと重要だ。「この戦略をテストした」と「この戦略がこれまでに生成したすべての過去シグナルの再現可能・バージョン管理されたレコードがある」の間にある差は巨大だ。前者はスクリーンショット、後者は監査証跡だ。CIは、毎回のプッシュで手動の手間をかけずに後者を維持する手段だ。
PineForgeがここに適しているのは、ランタイムがDockerイメージだからです。Dockerが動く場所ならどこでも動き、同じ入力には同じ取引を返し、jqでパースできるJSONレポートを出力します。ブラウザもログインも不要で、バックテスト自体にレート制限もありません。
GitHub Actionsの例
ワークフロー全体では、戦略をチェックアウトし、pineforge-releaseイメージでC++にトランスパイルし、固定したOHLCV CSVでバックテストを実行して、JSONレポートを次のツールでパースします:jq。続いてレポートをコミット済みのベースラインと比較し、いずれかの取引の売買方向またはバーが変わった場合や、純損益の差分が閾値を超えた場合に、ビルドを失敗させます。
ランタイムはコンテナイメージなので、管理が必要なAPIキーはありません。ジョブ内でdocker runするだけです。次の内容で.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 1ベースラインレポートをbaseline/report.jsonとしてリポジトリに保存し、戦略ソースと並べてコミットする。PRが意図的に戦略の動作を変更する場合、作成者はPRの一部としてベースラインを更新する。その差分が、何がなぜ変わったかの永続的なレコードになる。
同じワークフローは、GitLab CIやBitbucket Pipelines、Dockerが動く任意のランナーでも、まったく同じように動きます。YAMLの構文は書き換え、シェルコマンドはそのまま使います。
終了コードとパリティの検証
PineForgeランタイムは正常実行で終了コード0を返し、失敗時には0以外の終了コードを返します。入力ファイルがない場合は2、コンパイルの失敗は3、バックテストの失敗は4、トランスパイルの失敗(未対応のPineや構文エラー)は5です。CIは特別な処理をしなくても、これらの終了コードを検知します。
結果のチェック方法はワークフロー側で決めます。レポートは次のような形式です(抜粋):
{
"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:...", ... }
}簡単な数値パリティチェックにはsummary.net_pnlとsummary.total_tradesをベースラインと比較します。上のワークフローでは、trades配列も読み取ります。各取引の売買方向、エントリーバー、決済バーが変わっていないことが条件です。チェックに失敗すると、ワークフローは2つの取引一覧を1行に1取引ずつ並べてdiffを取ります。そのため、diffの行番号がそのまま取引番号になり、各行には取引の売買方向、バー、価格、数量、損益が変更前と変更後で表示されます。
数値フィールドの実用的な閾値:0.01(純損益の1セント)までの浮動小数点の差分を許容し、それより大きい差分は失敗とします。取引には許容範囲を設けません。上のワークフローは、売買方向、entry_bar_indexやexit_bar_indexのいずれかが変われば、損益にかかわらず失敗します。取引が別のバーにずれているなら、シグナルロジックが変わったということです。
実践的なパターンとして、ベースラインの更新を、PRラベルで制御する別のワークフローステップで行う方法があります。こうすると、意図的な変更が、その原因となったPineの変更と並んでコードレビューに表示されます。