PineForge는 CI에서 Pine Script를 백테스트하고, 트레이드가 달라지면 빌드를 실패시킵니다.
CI에 패리티를 거는 이유
전략도 여느 코드처럼 드리프트합니다. 파라미터 이름 변경, 헬퍼 함수 리팩터링, 미처 알아채지 못한 Pine v6 API 변경 중 무엇이든 기존 테스트를 하나도 깨지 않고 시그널을 조용히 바꿀 수 있습니다. CI에 패리티 게이트가 없으면 두 달 전에 한 커밋이 지난주 트레이드 목록을 바꿔 놓아도, 라이브 P&L이 이상해 보이고 나서야 알게 됩니다.
참조 트레이드 목록을 전략 소스와 함께 커밋하는 규율은 소프트웨어를 믿을 수 있게 만드는 규율과 같습니다. 기대 출력을 적어 두면, 실제 출력이 어긋날 때 빌드가 실패합니다. 백테스트에서 기대 출력은 고정해 둔 과거 데이터셋으로 돌린 트레이드 목록(진입 봉, 청산 봉, 방향, 수량, 체결가)입니다.
그 비교가 커밋마다 돌면 래칫이 생깁니다. 전략의 과거 동작은 좋아질 수는 있어도 실수로 나빠지지는 않습니다. 출력을 바꾼 커밋을 정확히 압니다. 그 커밋에서 빌드가 깨졌으니까요. 에퀴티 커브에 대한 git blame이 생깁니다.
처음엔 잘 모르는 퀀트에게 특히 큽니다. "전략을 테스트했다"와 "이 전략이 낸 모든 과거 시그널의 재현 가능·버전관리 기록이 있다" 사이 간격은 엄청납니다. 전자는 스크린샷, 후자는 감사 추적입니다. CI는 매 푸시마다 손으로 안 하고 후자를 유지하는 방법입니다.
PineForge가 여기에 잘 맞는 이유는 런타임이 Docker 이미지이기 때문입니다. Docker가 도는 곳이면 어디서든 돌아가고, 입력이 같으면 트레이드도 같으며, jq로 파싱할 수 있는 JSON 리포트를 출력합니다. 브라우저도, 로그인도, 백테스트 자체에 대한 호출 제한도 없습니다.
GitHub Actions 예시
전체 워크플로: 전략 체크아웃, pineforge-release 이미지로 C++ 트랜스파일, 고정해 둔 OHLCV CSV로 백테스트 실행, jq로 JSON 리포트 파싱, 커밋된 베이스라인과 비교, 트레이드 하나라도 방향이나 봉이 바뀌거나 순 PnL 델타가 임계를 넘으면 빌드 실패.
런타임이 컨테이너 이미지라서 관리할 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 배열도 읽습니다. 트레이드마다 방향과 진입 봉, 청산 봉이 그대로여야 합니다. 검사에 실패하면 워크플로가 두 트레이드 목록을 한 줄에 트레이드 하나씩 적어 디프합니다. 그래서 디프 결과의 줄 번호가 곧 트레이드 번호이고, 줄마다 트레이드의 방향, 봉, 가격, 수량, 손익을 변경 전과 후로 볼 수 있습니다.
수치 필드 실무 임계: 부동소수점 델타를 0.01 (순손익 1센트)까지 허용하고 이를 넘으면 실패. 트레이드에는 허용 오차가 없습니다. 위 워크플로는 손익과 상관없이 방향, entry_bar_index나 exit_bar_index가 하나라도 바뀌면 실패합니다. 트레이드가 다른 봉에 찍혔다는 건 시그널 로직이 바뀌었다는 뜻입니다.
실용적인 패턴 하나: 베이스라인 갱신을 PR 라벨로 제어하는 별도 워크플로 단계로 두면, 의도한 변경이 그 원인이 된 Pine 변경과 나란히 코드 리뷰에 드러납니다.