페어 트레이딩을 끝까지 만들어 봤다 — +226% 페어의 정체
스크리너, 공적분 검정, 칼만 필터, 15쌍 포트폴리오까지 직접 만들어 돌렸습니다. 3개월 +226%가 찍힌 페어가 왜 착시였는지, 화려한 백테스트 곡선을 어디부터 의심해야 하는지 정리했습니다.
백테스트 결과에 3개월 +226%가 찍힌 페어가 하나 있었습니다. 스크리너가 뱉어낸 수백 개 조합 중 성적이 제일 좋은 한 쌍이었고, 저는 그 자산곡선을 한참 들여다봤습니다. 결론부터 쓰면 그건 전략이 아니라 제 코드가 만들어낸 착시였습니다. 이 글은 스크리너부터 공적분 검정, 칼만 필터, 15쌍 포트폴리오까지 페어 트레이딩을 끝까지 만들어서 돌려본 기록이고, 동시에 그 +226%가 어디서 나왔는지 역추적한 기록입니다.
원리가 간단한 게 함정이다
같이 움직이는 두 자산이 있고, 둘 사이 가격 차이가 평소보다 벌어졌습니다. 비싼 쪽을 팔고 싼 쪽을 삽니다. 차이가 원래 자리로 돌아오면 방향과 무관하게 수익이 남습니다. 시장이 오르든 내리든 두 다리가 서로를 상쇄하니까요. 여기까지가 페어 트레이딩의 전부이고, 실제로 이보다 복잡한 설명은 필요 없습니다. 문제는 이 문장에 숨어 있는 단 하나의 가정입니다. "차이가 원래 자리로 돌아온다." 이건 관찰이 아니라 예측이고, 그 예측이 맞을 거라는 근거는 과거 데이터밖에 없습니다. 방향 리스크를 없앤 대가로 스프레드 리스크를 통째로 받은 겁니다. 그리고 스프레드는 방향보다 덜 흔들리기 때문에, 사람은 자연스럽게 레버리지를 올립니다. 평소엔 조용하다가 회귀 가정이 깨지는 날 한 번에 정리되는 손익 구조가 이렇게 완성됩니다. 개인이 이 전략에서 녹는 이유는 계산이 틀려서가 아니라, 안전해 보이는 구조에 레버리지를 얹었는데 그 안전이 통계적 가정이었기 때문입니다.
스크리너와 공적분 검정 — 통과의 대부분은 우연이다
먼저 상관계수는 후보 선별 도구로 쓸 수 없습니다. 특히 크립토에서는요. 알트코인 대부분이 BTC 유동성이라는 하나의 인자에 붙어 있어서 일봉 상관은 웬만하면 0.8을 넘습니다. 상관으로 거르면 유니버스 전체가 통과합니다. 공적분은 다른 질문을 합니다. 두 가격의 차이가 벌어졌을 때 다시 붙느냐. 그런데 여기서 진짜 문제는 검정 방법이 아니라 검정 횟수입니다. 100개 종목을 전부 조합하면 페어가 4,950개 나오고, p<0.05 기준이면 아무 관계가 없어도 순전히 우연으로 약 247개가 통과합니다. 스크리너 출력물을 그대로 유니버스로 쓰면 그 247개를 사는 겁니다. BTC/ETH처럼 가장 유명한 페어조차 표본 구간을 몇 달만 옮기면 통과와 탈락이 뒤집힙니다. 2021년 이후 ETH/BTC 비율에는 평균회귀보다 드리프트가 더 크게 섞여 있고, SOL을 끼우면 구간에 따라 결과가 더 흔들립니다. 아래 코드는 후보를 좁히는 용도이지 검증이 아닙니다.
import ccxt, numpy as np, pandas as pd
from itertools import combinations
from statsmodels.tsa.stattools import coint
ex = ccxt.binance({"options": {"defaultType": "future"}})
def logclose(symbol, timeframe="4h", limit=1000):
"""바이낸스 USDT-M 무기한 OHLCV -> 로그 종가 시리즈"""
o = ex.fetch_ohlcv(symbol, timeframe, limit=limit)
s = pd.Series([r[4] for r in o],
index=pd.to_datetime([r[0] for r in o], unit="ms"))
return np.log(s)
# 유니버스는 경제적 연결고리로 먼저 좁힌다. 전 종목 조합은 다중검정 지옥이다
UNIVERSE = ["BTC/USDT", "ETH/USDT", "SOL/USDT", "BNB/USDT", "AVAX/USDT"]
px = {s: logclose(s) for s in UNIVERSE}
rows = []
for a, b in combinations(UNIVERSE, 2):
y, x = px[a].align(px[b], join="inner")
# Engle-Granger 전용 임계값. 추정한 beta의 잔차에 일반 ADF를 쓰면 과대기각한다
rows.append((a, b, coint(y, x)[1]))
# 이 표는 '후보'다. p값 낮은 순 1등을 고르는 순간 그게 곧 데이터 스누핑이다
print(pd.DataFrame(rows, columns=["a", "b", "coint_p"]).sort_values("coint_p"))칼만 필터 — 헤지비율을 고정하지 않는다
헤지비율 β를 전 구간 OLS로 한 번 뽑으면 그 스프레드는 이미 미래를 본 계열입니다. 롤링 OLS로 바꾸면 미래참조는 사라지지만 룩백 길이라는 파라미터가 새로 생기고, 그 길이는 결국 백테스트 성적을 보고 고르게 됩니다. 칼만 필터는 매 봉마다 β를 갱신해서 이 룩백 문제를 없앤다고들 말합니다. 절반만 맞습니다. 룩백이 사라진 자리에 delta라는 파라미터가 들어옵니다. delta는 β가 얼마나 빨리 움직이게 허용할지를 정하는 값이고, 이걸 스윕하면 백테스트 성적이 크게 갈립니다. 파라미터를 없앤 게 아니라 이름을 바꾼 겁니다. 게다가 칼만은 시각적으로 위험합니다. β가 유연하니까 스프레드가 항상 0 근처에 붙고, 차트를 뽑으면 교과서에 나올 법한 아름다운 평균회귀 그림이 나옵니다. 그런데 β가 너무 빨리 따라가면 벌어져야 할 스프레드가 애초에 안 벌어지거나, 진입해도 β가 가격을 쫓아가버려서 되돌림이 남지 않습니다. 예쁜 스프레드 차트와 수익이 나는 스프레드는 다른 물건입니다.
import numpy as np
def kalman_hedge(y, x, delta=1e-4, r=1e-3):
"""상태 = [beta, intercept]. 매 봉 헤지비율을 갱신하고 표준화 스프레드를 낸다.
delta가 크면 beta가 가격을 졸졸 따라가고, 작으면 사실상 전구간 OLS가 된다."""
n = len(y)
b = np.zeros(2)
P = np.eye(2) * 1e2 # 확산 사전분포. 초기 수십 봉은 워밍업으로 버린다
Q = delta / (1 - delta) * np.eye(2) # 상태 잡음
beta, z = np.zeros(n), np.zeros(n)
for t in range(n):
F = np.array([x[t], 1.0]) # 관측 행렬
P = P + Q # 예측 단계
e = y[t] - F @ b # 관측 잔차 = 그 시점 스프레드
s = F @ P @ F + r # 잔차 분산
K = P @ F / s # 칼만 게인
b = b + K * e # 갱신 단계
P = P - np.outer(K, F) @ P
beta[t], z[t] = b[0], e / np.sqrt(s)
# z는 칼만이 내놓은 잔차 분산으로 표준화한다. 여기에 롤링 std를 또 씌우면 이중 평활
return beta, z청산 규칙에는 트레일링 스탑을 붙여봤습니다. 원래 어울리지 않는 조합입니다. 트레일링 스탑은 추세를 끝까지 따라가려고 만든 도구고, 평균회귀는 목표 지점이 z=0으로 이미 정해져 있는 전략이니까요. 그래도 붙인 이유는 스프레드가 끝내 안 돌아오는 케이스에서 손실 꼬리를 자르고 싶어서였습니다. 결과는 예상대로였습니다. 진입 후 유리하게 갔다가 z=0으로 수렴하는 정상 경로에서 트레일링이 먼저 걸려버립니다. 승률은 올라가고 건당 손익은 내려갑니다. 잘라야 할 꼬리는 그대로 두고 먹어야 할 몸통만 잘라낸 셈이에요. 같은 목적이라면 반감기의 3배가 지나면 z와 무관하게 정리하는 시간 손절이 훨씬 잘 맞았습니다. 평균회귀에서 시간은 그 자체로 비용이고, 예정된 시각에 안 돌아온 스프레드는 대개 앞으로도 안 돌아옵니다.
15쌍 포트폴리오, 그리고 +226%의 정체
한 쌍이 깨져도 계좌가 같이 깨지지 않게 15쌍으로 늘렸습니다. 주식 페어라면 산업군을 흩어놓는 것으로 어느 정도 분산이 되는데, 크립토에서는 이게 잘 안 됩니다. 15쌍 전부가 결국 BTC 유동성이라는 하나의 인자 위에 얹혀 있어서, 리스크오프가 들어오면 15개 스프레드가 같은 날 같은 방향으로 벌어집니다. 쌍 수를 늘려서 산 건 분산이 아니라 수수료였습니다. 그리고 +226% 페어를 뜯어보니 세 가지가 겹쳐 있었습니다. 첫째, 그건 수백 조합 중 1등입니다. 공적분이 전혀 없는 무작위 시계열 쌍으로 같은 스크리너를 돌려도 1등 자리에는 세 자릿수 수익률이 앉습니다. 최댓값의 분포를 보고 전략의 실력이라고 부른 겁니다. 둘째, 수익의 대부분이 서너 번의 거래에 몰려 있었고 그 거래들은 전부 호가창이 얇은 알트에서 났습니다. 체결 가정을 종가에서 호가 기반 슬리피지로 바꾸는 순간 그 구간이 통째로 사라졌습니다. 셋째, 그 곡선은 β를 전 구간으로 한 번 추정하던 초기 버전에서 나온 겁니다. 칼만으로 갈아끼우자 곡선이 평평해졌고, 아웃오브샘플로 다음 구간을 돌리니 아무 일도 일어나지 않았습니다.
왜 이게 실전에서 깨지는가
- 이 글에 제 백테스트 성과 숫자를 적지 않은 건 겸손이 아니라 일관성입니다. 위에서 +226%가 왜 무의미한지 설명해놓고 그 아래에 제 곡선의 숫자를 적으면 같은 착시를 한 번 더 파는 게 됩니다. +226%는 결과가 아니라 전시물로 인용한 겁니다.
- 공적분은 표본 안의 성질이고 미래의 약속이 아닙니다. 크립토는 쓸 만한 히스토리가 짧은데 그 안에서 레짐이 여러 번 바뀌었고, 검정을 통과한 페어가 몇 달 만에 조용히 죽습니다. 통과 시점의 p값 하나로 페어를 확정하지 마세요.
- 조합 수가 곧 과최적화입니다. 룩백, z 진입·청산 임계, 칼만 delta, 관측잡음 r, 트레일링 폭, 리밸런싱 주기. 여섯 개만 각각 열 개씩 넣어도 백만 조합이고, 1등은 반드시 나옵니다. 워크포워드로 구간을 굴리며 재추정하지 않았다면 그건 검증이 아니라 선택입니다.
- 비용이 두 배로 들어옵니다. 다리가 둘이니 왕복이면 taker 체결 네 번이고, 무기한 선물이면 양쪽 다리에 펀딩이 각각 붙습니다. 스프레드에서 노리는 폭이 작을수록 비용 비중이 커져서, 이 전략의 기대값은 비용 가정 위에 아슬아슬하게 얹혀 있습니다. 자기 계좌의 실제 체결가로 다시 계산하기 전까지는 미정입니다.
- 종가 체결 가정은 백테스트 전용입니다. 실전에서는 한쪽 다리만 체결되는 레그 리스크가 있고, 그 순간 시장 중립이 아니라 방향 베팅이 됩니다. 얇은 알트에서 한 다리를 못 빼면 스프레드를 해제할 방법 자체가 없어집니다.
- 두 다리 델타가 0이어도 증거금은 중립이 아닙니다. 한쪽이 급등하면 그쪽 손실은 즉시 증거금에서 빠지는데 반대쪽 평가익은 마진으로 안 잡히는 구간이 생깁니다. 강제 감축으로 한 다리가 먼저 잘리면 손실이 그 체결가로 확정됩니다.
- 15쌍은 분산으로 안 보이는 게 정상입니다. 크립토 페어의 상관은 위기 때 1로 수렴합니다. 쌍 수를 늘리기 전에, 그 쌍들이 서로 다른 이유로 움직이는지부터 확인하세요.
- 이 글과 첨부 코드는 전략 구조를 설명하려고 만든 교육·연구 자료입니다. 특정 종목이나 매매를 권하지 않습니다. 코드를 공개하는 이유는 따라 하라는 게 아니라, 조건을 바꿔가며 직접 깨뜨려 보라는 겁니다. 제 결과도 믿지 말고 본인 데이터로 다시 돌려보세요.
첨부파일
영상에 사용된 소스코드와 설명서입니다. 로그인 없이 받을 수 있습니다.
페어 트레이딩 백테스트 소스코드 (칼만 헤지비율)공적분 스크리닝 결과 (바이낸스 무기한)백테스트 착시 체크리스트