2026년 9월 11일 · 재현성

같은 코드, 같은 데이터, 서로 다른 Sharpe: 백테스트에 필요한 비결정성 감사

같은 코드, 같은 데이터, 서로 다른 Sharpe: 백테스트에 필요한 비결정성 감사

같은 커밋, 같은 parquet 파일, 같은 컴퓨터에서 한 시간 간격으로 두 번 실행했습니다. Sharpe는 1.34와 1.19였습니다. 총수익률은 41.2%와 37.8%였습니다. 거래 수는 1,418건과 1,421건이었습니다. 두 자산 곡선은 11,205개 봉까지 출력된 모든 소수 자릿수가 일치하다가 갈라졌습니다.

0.15입력은 같은데 Sharpe가 벌어진 폭
3 / 1,418차이가 난 거래
11,205갈라지기 전까지 일치한 봉 수

앞의 세 숫자는 거슬립니다. 마지막 숫자가 흥미로운 이유는 문제의 원인이 실행 내내 누적된 부동소수점 오차가 아니라는 점을 보여주기 때문입니다. 특정 봉에서 불연속적인 일이 일어났고, 그 뒤로 차이가 누적됐습니다. 이 글에서는 그 봉을 찾아내고, 0.15라는 차이가 지금까지 수행한 모든 파라미터 스윕에 어떤 영향을 주는지 살펴봅니다.

전략은 거래량 기준 상위 30개 USDⓈ-M 무기한 계약을 대상으로 한 횡단면 모멘텀입니다. 4시간마다 리밸런싱하고, 12시간 수익률 기준 상위 5개 종목을 롱, 하위 5개를 숏으로 보유합니다. 과거 데이터는 18개월분이며, 수수료와 펀딩은 각 레그에 부과했습니다.

두 실행이 갈라진 봉 찾기

봉마다 자산을 최대 정밀도로 기록했다면 이 작업은 4분 정도면 끝납니다. 두 실행 결과를 (bar_index, equity, open_positions_hash) 형식의 CSV로 내보내 불러온 다음, 처음으로 값이 달라지는 인덱스를 찾으면 됩니다. 다른 버그를 잡으려고 이미 스크립트를 만들어둔 덕분에 오전 내내 시간을 허비하지 않았습니다.

11,206번째 봉, 2025-03-14T08:00 UTC였습니다. 직전 봉까지 자산은 유효숫자 13자리까지 같았습니다. 11,206번째 봉에서 포지션 해시가 달랐습니다. 실행 A는 SOL 롱, 실행 B는 AVAX 롱이었습니다. 방향과 명목 금액은 같고 종목만 달랐습니다. 이후의 차이는 모두 여기서 파생됐습니다.

그래서 두 실행에서 해당 봉의 순위 산정 입력값을 출력했습니다. 값은 동일했습니다. 바이트 단위까지 같았고, 종목 30개와 점수 30개도 모두 일치했습니다. 점수 둘은 0.0였습니다. 정확히 0이었습니다. 두 종목 모두 조회 구간에 거래가 없는 4시간 봉이 기록돼 종가 대비 종가 비율이 1이 됐고, 로그를 취하니 0이 된 것입니다. 0으로 반올림된 게 아니라 실제로 0이었습니다.

두 종목이 5위에서 동점이었습니다. 상위 5개를 선택했으니 둘 중 어느 종목이 들어갈지는 정렬 결과에서 남은 순서에 달려 있었습니다. 정렬은 제가 생각한 대로 동작하지 않았습니다.

동점, 불안정 정렬, 그리고 2년 동안 외면했던 문제

순위 계산은 그룹 집계를 거쳤고, 여기에 들어가는 프레임은 비동기 가져오기로 봉마다 다시 만든 종목 집합을 순회하는 딕셔너리 컴프리헨션에서 생성됐습니다. 집합의 순회 순서는 해시 시드에 따라 달라지고, Python은 PYTHONHASHSEED을 고정하지 않으면 프로세스마다 문자열 해시 시드를 무작위로 정합니다. 따라서 정렬 전 행 순서가 실행마다 달랐고, 동점 키를 불안정 정렬로 정렬하면서 동점 처리 결과도 달라졌습니다.

이런 동점은 드문 예외가 아니라 구조적으로 생깁니다. 특성이 포화되거나 값이 잘리는 곳이면 어디서든 정확히 같은 값이 만들어집니다. 거래량이 0인 봉은 수익률이 정확히 0이고, 잘린 z 점수는 ±3.0으로 고정되며, 서로 다른 값이 적은 순위 변환에서는 수십 개가 동점이 됩니다. 불리언 필터는 통과한 모든 항목에 1.0을 부여합니다. 이 실행에는 4시간 봉 기준 18개월 동안 선택 경계에서 동점이 발생한 봉이 47개 있었습니다. 그중 3개에서 선택 종목 구성이 바뀌었습니다. 나머지는 이미 모두 포함된 종목끼리, 또는 모두 제외된 종목끼리 동점이었습니다.

하루 정도는 데이터 로더가 비결정적으로 동작한다고 확신했습니다. 그게 흥미로운 답처럼 보였으니까요. 하지만 원인은 아니었습니다. 그런 경우는 거의 없습니다. 집합과 동점, 그리고 아무도 문서화하지 않은 정렬 안정성 가정이 문제였습니다.

거래 3건이 Sharpe 0.15 차이를 만드는 이유

많은 사람이 이 대목에 반론을 제기합니다. 답은 자산 비율 기준으로 포지션 크기를 정하는 백테스트가 경로 의존 시스템이라는 것입니다. 각 레그에 현재 자산의 8%를 투입하면, n번째 봉의 자산 차이가 그 이후 n번째 봉부터 모든 명목 금액의 차이로 이어집니다.

처음 갈라진 지점 자체의 영향은 크지 않았습니다. 실행 B의 AVAX 레그는 9시간 동안 2.1% 손실을 냈고, 실행 A의 SOL 레그는 0.4% 수익을 냈습니다. 해당 거래 이후 자산 차이는 0.21%였습니다. 사소한 수준이었습니다. 하지만 그때부터 두 실행은 더 이상 같은 전략이 아니었습니다. 포지션 크기가 조금씩 달라 펀딩 누적액도 미세하게 달라졌고, 보유 포지션이 약간 달라지면서 점수 입력값이 달라져 이후 경계에서 발생한 동점 2건의 결과도 다시 달라졌습니다. 그중 하나는 2025-03-27에 발생했습니다. 전체 손익의 약 3분의 1을 만들어낸 6일간의 추세가 시작되기 하루 전이었습니다. 실행 A는 추세 내내 포지션을 보유했지만, 실행 B는 리밸런싱 한 번 늦게 진입했습니다.

수익률 차이는 3.4%포인트였습니다. 실행 B에서 거래 순서가 바뀐 구간의 변동성이 더 커 분모는 올라가고 분자는 내려갔기 때문에, Sharpe 차이는 수익률 차이가 시사하는 것보다 컸습니다. 작은 원인에 두 가지 증폭 요인이 더해졌습니다.

명목 금액을 고정하고 진입 조건이 현재 보유 포지션에 좌우되지 않는다면 이런 영향에서 훨씬 자유롭습니다. 하지만 흥미로운 전략 대부분은 둘 다 해당하지 않습니다.

실제로 문제가 생기는 5가지 지점

원인증상해결책
고정하지 않은 PYTHONHASHSEED에서 집합/딕셔너리 순회 순서가 정렬에 영향동점 처리 결과가 실행마다 바뀌며, 특정 봉에서 처음 차이가 발생시드를 고정하고 종목 심볼을 명시적인 2차 정렬 키로 지정해 동점 처리도 결정적으로 만들기
동점 키에 불안정 정렬 사용 (quicksort NumPy/pandas 기본값)위와 같은 현상이 시드를 고정해도 계속됨kind="stable" 사용 또는 정렬 키에 완전한 순서 부여
부트스트랩, 학습/테스트 셔플, 합성 체결 흔들림에 시드 없는 RNG 사용전체 실행 결과가 달라지고, 명확한 최초 차이 지점이 없음구성 요소마다 명시적인 시드 하나를 지정하고 실행 매니페스트에 기록
병렬 부동소수점 축약 (스레드 수에 따라 합산 순서가 달라짐)마지막 몇 비트에서 차이가 발생. 대개 무해하지만 임계값 비교를 넘으면 문제가 됨연구 실행의 스레드 수를 고정하고, 의사결정 경계에서 부동소수점 값을 ==로 비교하지 않기
고정하지 않은 라이브러리 버전오늘은 재현되지만 11월에는 재현되지 않음데이터 스냅샷 해시와 함께 잠금 파일 해시를 매니페스트에 기록

네 번째 항목은 사람들이 걱정하는 만큼 자주 문제를 일으키지 않고, 첫 번째 항목은 끊임없이 발목을 잡습니다.

비트 단위 재현성은 도구이지 미덕이 아닙니다

한 줄을 바꿨을 때 자산 곡선의 변화가 그 줄에서 비롯됐다고 판단할 수 있도록 결정성을 확보해야 합니다. 그게 전부입니다. 이제 Stratmill의 모든 에이전트 실행은 데이터 스냅샷 해시, 잠금 파일 해시, 모든 시드가 담긴 매니페스트를 기록합니다. 다시 실행한 결과가 이전 곡선과 비트 단위까지 일치하지 않으면, 호기심을 자극하는 일이 아니라 빌드 실패로 처리합니다.

하지만 재현할 수 있게 된 뒤에는 의도적으로 결과를 흔들어 보세요. 64개 시드로 64번 실행하고 분포를 확인합니다.

흔들림 범위입니다. 동일한 전략과 데이터로 시드가 지정된 동점 처리 및 체결 순서 조합 64개를 실행했습니다. Sharpe p5는 1.12, 중앙값은 1.27, p95는 1.41이었습니다. 범위 폭은 0.29였습니다.

이제 파라미터 스윕 결과를 다시 보겠습니다. 최고 설정의 점수는 1.46이었습니다. 96개 중 40위인 설정은 1.31이었습니다. 두 설정의 차이는 0.15로, 흔들림 범위의 절반에 불과합니다. 스윕은 두 설정의 순위를 매긴 게 아닙니다. 각 설정에서 나온 분포 표본을 하나씩 뽑아 정렬했을 뿐입니다.

이런 관점의 변화는 설정을 고르는 방식도 바꿨습니다. 설정 간 점수 차이가 개별 설정의 흔들림보다 커야 스윕 결과를 순위로 볼 수 있습니다. 거래 1,400건이 있는 경로 의존 전략에서는 흔들림이 대개 커서 순위표 상위 3분의 1이 통째로 동점이 됩니다. 그럴 때는 흔들림 범위에 가려지지 않는 기준으로 고르세요. 회전율이 더 낮은지, 파라미터가 더 적은지, 회의적인 사람에게도 설명할 수 있는 비용 가정인지, 선호하는 워크포워드 구간 중 가장 성과가 나쁜 곳에서 더 잘 동작하는지 살펴보세요. 이런 기준은 실제로 동점을 가릅니다. Sharpe가 0.15 높다는 사실은 그렇지 않습니다.

이 결과를 믿기 전에 할 일이 하나 더 있습니다. 지금 백테스트를 두 번 실행하고 봉별 자산을 비교해, 비트 단위로 일치하는 쪽인지 0.15 차이가 나는 쪽인지 확인하세요. 15분이면 할 수 있는 실험이며, 지금까지 쌓은 연구 이력 중 얼마나 많은 부분이 전략을 측정했고 얼마나 많은 부분이 해시 시드를 측정했는지 알려줍니다.

결정성백테스팅데이터 엔지니어링과적합파이썬
← 전체 글