CI / GitHub Actions

CI에서 PineScript 백테스트 (GitHub Actions, GitLab, Docker 러너면 무엇이든)

전략 저장소에 커밋할 때마다 백테스트가 실행됩니다. 패리티를 검증하고 회귀면 빌드를 깹니다. 로컬과 같은 Docker 이미지를 러너에서 실행합니다.

CI에 패리티를 거는 이유

전략도 코드처럼 드리프트합니다. 파라미터 이름 변경, 헬퍼 리팩터, 놓친 Pine v6 API 변경 — 다 기존 테스트를 깨지 않고 시그널만 살짝 바꿀 수 있습니다. CI에 패리티 게이트가 없으면 두 달 전 커밋이 지난주 트레이드 목록을 바꿔도 라이브 P&L이 이상해질 때까지 모릅니다.

참조 트레이드 목록을 전략 소스와 함께 커밋하는 규율은 소프트웨어를 믿게 만드는 규율과 같습니다: 기대 출력을 적고 실제 출력이 어긋나면 빌드를 깹니다. 백테스트에선 기대 출력이 고정 과거 데이터에 대한 트레이드 목록 — 진입 바, 청산 바, 방향, 크기, 체결가 — 입니다.

그 비교가 매 커밋마다 돌면 래칫이 생깁니다 — 과거 동작은 좋아질 수는 있어도 실수로 나빠지진 않습니다. 출력을 바꾼 커밋을 exactly 압니다. 그 커밋에서 빌드가 깨졌으니까요. 에퀴티 커브에 대한 git blame이 생깁니다.

처음엔 잘 모르는 퀀트에게 특히 큽니다. "전략을 테스트했다"와 "이 전략이 낸 모든 과거 시그널의 재현 가능·버전관리 기록이 있다" 사이 간격은 엄청납니다. 전자는 스크린샷, 후자는 감사 추적입니다. CI는 매 푸시마다 손으로 안 하고 후자를 유지하는 방법입니다.

PineForge가 가능한 이유는 런타임이 Docker 이미지이기 때문입니다 — Docker 도는 곳이면 어디서나, 같은 입력이면 결정론적 출력, 쉘에서 파싱 안전한 안정 JSON 스키마. 브라우저 없음, 인증 플로 없음, 백테스트 실행 자체의 레이트 리밋 없음.

GitHub Actions 예시

전체 워크플로: 전략을 체크아웃하고, 런타임 컨테이너로 C++로 트랜스파일하고, 고정해 둔 OHLCV CSV로 백테스트를 돌린 뒤, JSON 리포트를 파싱합니다 — jq로 JSON 리포트 파싱 → 커밋된 베이스라인과 비교 → 순 PnL 델타가 임계를 넘으면 빌드 실패.

런타임은 컨테이너 이미지라 관리할 API key가 없습니다 — 잡에서 그냥 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@v4

      - 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-engine:latest > 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-engine:latest \
            > 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/report.json에 두고 전략 소스와 함께 커밋합니다. PR이 의도적으로 동작을 바꾸면 작성자가 베이스라인을 PR 안에서 갱신합니다. 그 디프가 무엇이 왜 바뀌었는지 영구 기록이 됩니다.

같은 워크플로는 GitLab CI, Bitbucket Pipelines, Docker를 쓰는 러너면 동일하게 실행합니다 — YAML만 바꾸고 쉘 커맨드는 유지.

종료 코드와 패리티 검증

PineForge 런타임은 정상 실행 시 종료 코드 0를 내고, 엔진 오류면 0이 아닙니다(잘못된 Pine, 미지원 빌트인, 데이터 파싱 실패). CI가 종료 코드를 자동으로 집습니다 — 엔진 실패용 특별 처리 불필요.

패리티 검증은 별개 관심사로 워크플로 안에서 구현합니다. JSON 리포트 스키마는 패치 버전 간 안정적입니다:

{
  "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 를 베이스라인과 비교합니다. 엄격 트레이드 단위 패리티는 전체 trades배열을 디프합니다 — 진입·청산 바 인덱스, 방향, 체결가가 모두 맞아야 합니다. 안 맞으면 디프가 어떤 트레이드가 어떤 바에서 얼마나 어긋났는지 집어줍니다.

수치 필드 실무 임계: 부동소수점 델타를 0.01(1센트, 또는 정규화 시계열에서 1bp)까지 허용하고 그 이상은 실패. 트레이드 단위 디프에선 entry_bar exit_bar불일치는 PnL 근접과 무관하게 항상 하드 실패입니다 — 다른 바에 트레이드가 붙었다는 건 시그널 로직이 바뀌었다는 뜻입니다.

패리티 CI를 도입한 팀은 보통 베이스라인 업데이트를 PR 라벨로 게이트한 별도 워크플로 스텝으로 둡니다. 의도된 개선이 PR 타임라인에 남고 베이스라인 디프가 원인 된 Pine 변경과 함께 리뷰에 뜹니다.

시작