롱숏으로 돈 벌겠다고 만든 봇 — 15쌍에서 뭐가 깨졌나
공적분으로 페어를 고르고 칼만 필터로 헤지비율을 갱신하는 롱숏 봇을 바이낸스에 붙여 돌렸습니다. 코드와 함께, 왜 이 구조가 실전에서 버티지 못했는지 정리했습니다.
앞 편에서 롱숏이라는 구조를 훑었으니, 이번엔 실제로 돌아가는 프로그램을 만들었습니다. 바이낸스 선물 API에 붙여서 페어를 고르고, 헤지비율을 계속 갱신하고, 진입·청산·손절을 자동으로 넣는 것까지. 03:05부터 10:22까지 7분이 통째로 개발 과정입니다. 결론부터 말하면 제가 기대한 수익 곡선은 안 나왔고, 안 나온 이유가 코드 버그가 아니라 전략 구조 자체에 있었다는 게 이 글의 요지입니다. 영상 챕터 순서를 그대로 따라가면서 각 단계에서 뭘 했고 어디서 어긋났는지 적었습니다.
롱숏 전략 — 방향을 안 맞히겠다는 선언 (02:29)
롱숏은 한쪽을 사고 다른 쪽을 파는 겁니다. BTC가 오를지 내릴지는 안 맞힙니다. 대신 'ETH가 BTC 대비 싸졌다'처럼 두 자산 사이의 상대 가격에 겁니다. 시장이 통째로 20% 빠져도 두 다리가 같이 빠지면 손익에는 그 차이만 남습니다. 이론상 그렇다는 뜻이고, 실제로 그렇게 되는지가 이번 실험의 질문이었어요. 주식에서 이 전략이 성립하는 이유는 같은 업종 두 회사가 같은 원자재를 사고 같은 고객에게 팔기 때문입니다. 가격이 벌어질 이유도, 다시 좁혀질 이유도 실물에 있습니다. 코인에는 그런 실물 고리가 거의 없어요. BTC와 ETH가 같이 움직이는 건 사업 구조가 닮아서가 아니라 같은 위험자산 바구니에 담겨 있어서고, 그 바구니는 시장 분위기가 바뀌면 다시 짜입니다. 이 차이가 뒤에서 전부 비용으로 돌아옵니다.
프로그램 개발 ① 공적분으로 후보를 거른다 (03:05)
유니버스는 바이낸스 USDT 무기한 중 거래대금 상위 종목으로 잡았습니다. BTC, ETH, SOL을 축으로 두고 거기 붙는 대형 알트를 더해 조합을 만들면 후보가 수십 개 나오는데, 여기서 공적분 검정으로 걸러 15쌍을 남겼습니다. 상관계수를 안 쓰는 이유는 분명합니다. 상관은 두 종목이 같은 날 같은 방향으로 움직였는지만 봅니다. 나란히 손잡고 절벽으로 떨어져도 0.95가 나와요. 공적분은 다른 질문을 합니다 — 벌어진 차이가 다시 좁혀지느냐. 롱숏이 돈을 버는 지점은 두 번째 성질이고, 첫 번째 성질만 보고 페어를 고르면 벌어진 채로 영영 안 돌아오는 쌍을 사게 됩니다. 다만 조합을 많이 돌릴수록 아무 관계 없는 쌍도 우연히 p<0.05를 통과합니다. 그래서 p값은 합격증이 아니라 최소 조건으로만 씁니다.
import ccxt, itertools, numpy as np, pandas as pd
import statsmodels.api as sm
from statsmodels.tsa.stattools import coint
ex = ccxt.binance({"options": {"defaultType": "future"}})
# 지면용으로 줄인 목록. 실제 유니버스는 거래대금 상위에서 뽑아 이보다 큽니다
SYMBOLS = ["BTC/USDT", "ETH/USDT", "SOL/USDT", "BNB/USDT", "XRP/USDT", "AVAX/USDT"]
def close_series(symbol: str, limit: int = 1000) -> pd.Series:
# 1시간봉 종가. 마지막 봉은 아직 안 닫혔으므로 버린다
ohlcv = ex.fetch_ohlcv(symbol, timeframe="1h", limit=limit)[:-1]
df = pd.DataFrame(ohlcv, columns=["ts", "o", "h", "l", "c", "v"])
return pd.Series(df["c"].values, index=pd.to_datetime(df["ts"], unit="ms"))
# 심볼마다 시작 시각이 다를 수 있으므로 반드시 시각 기준으로 정렬해서 합친다
px = pd.DataFrame({s: close_series(s) for s in SYMBOLS}).dropna()
rows = []
for a, b in itertools.combinations(SYMBOLS, 2):
ly, lx = np.log(px[a]), np.log(px[b])
# Engle-Granger. beta를 추정해 만든 잔차라 일반 ADF 임계값을 쓰면 과대기각한다
pval = coint(ly, lx)[1]
# coint()는 (t통계량, p값, 임계값)만 돌려준다. 헤지비율은 1단계 회귀에서 따로 구한다
beta = sm.OLS(ly, sm.add_constant(lx)).fit().params.iloc[1]
rows.append((a, b, pval, beta))
cand = pd.DataFrame(rows, columns=["y", "x", "pval", "beta"]).sort_values("pval")
# 조합이 많을수록 우연 통과도 늘어난다. p<0.05는 '합격'이 아니라 '최소 조건'
print(cand[cand.pval < 0.05])프로그램 개발 ② 헤지비율은 고정값이 아니다 — 칼만 필터
공적분 검정의 1단계 회귀에서 헤지비율 β가 같이 나옵니다. ETH를 100달러어치 팔 때 BTC를 몇 달러어치 사야 방향 노출이 0에 가까워지는가. 문제는 이 β를 과거 전 구간으로 한 번 구해서 박아두면 그 순간부터 스프레드가 실제 관계에서 조금씩 어긋난다는 겁니다. 코인은 이 어긋남이 유난히 빠릅니다. SOL이 몇 달 만에 시장에서 차지하는 위치를 바꿔버리면, 예전 β는 이미 다른 자산의 헤지비율이에요. 그래서 β를 상태 변수로 두고 봉이 하나 닫힐 때마다 갱신하는 쪽으로 갔습니다. 칼만 필터가 하는 일이 정확히 그겁니다. 롤링 회귀로도 비슷하게 갈 수 있지만, 롤링은 창 길이를 정하는 순간 그 안의 데이터를 전부 같은 무게로 취급합니다. 칼만은 최근 관측에 더 무게를 주면서 갱신 속도를 delta 하나로 조절할 수 있어요. 그리고 그 delta가 이 전략에서 제일 위험한 손잡이입니다.
import numpy as np, pandas as pd
def kalman_hedge(ly: pd.Series, lx: pd.Series, delta: float = 1e-4, r: float = 1e-3):
"""로그가격 y ~ beta*x + alpha 를 매 봉 갱신. 반환: (beta 시계열, 스프레드 시계열)
delta가 크면 beta가 시장을 빨리 따라가지만 노이즈까지 쫓아간다.
성과를 보면서 이 값을 맞추기 시작하면 그때부터는 과최적화다.
주의: theta=0, P=I 에서 시작하므로 초기 구간은 beta가 아직 수렴 전이다.
앞쪽 수십~수백 봉은 burn-in으로 버리고 진입 신호로 쓰지 말 것.
"""
n = len(ly)
theta = np.zeros(2) # 상태 [beta, alpha]
P = np.eye(2) # 상태 공분산
Q = delta / (1 - delta) * np.eye(2) # 상태 잡음 — delta로 갱신 속도 조절
beta_out, spread_out = np.empty(n), np.empty(n)
for t in range(n):
H = np.array([lx.iloc[t], 1.0]) # 관측행렬
P = P + Q # 예측 (theta는 랜덤워크 가정이라 그대로 둔다)
e = ly.iloc[t] - H @ theta # 예측 잔차 = 그 시점의 스프레드 (갱신 전 값이라 미래참조 없음)
s = H @ P @ H + r # 잔차 분산
K = P @ H / s # 칼만 게인
theta = theta + K * e # 갱신
P = P - np.outer(K, H) @ P
beta_out[t], spread_out[t] = theta[0], e
return pd.Series(beta_out, ly.index), pd.Series(spread_out, ly.index)15쌍 운용, 트레일링 스탑, 그리고 돌려본 결과 (10:22)
운용은 15쌍에 자본을 균등 배분하고, 각 쌍의 스프레드를 z-score로 표준화해서 임계값을 넘으면 진입, 0 근처로 돌아오면 청산하는 구조입니다. 한 쌍에 몰지 않는 게 핵심이라고 생각해서 쌍 수를 늘렸어요. 여기에 트레일링 스탑을 붙였습니다. 스프레드가 유리한 쪽으로 벌어지면 손절선을 따라 올려서 되돌림에 이익을 다 반납하지 않게 하는 장치죠. 그런데 붙이고 나서야 알았는데, 이게 전략 논리와 정면으로 부딪힙니다. 평균회귀는 '벌어졌으니 좁혀질 것'에 거는 건데, 트레일링 스탑은 '가던 방향으로 더 간다'를 전제로 만든 도구예요. 추세추종에서 가져온 부품을 평균회귀 엔진에 끼운 셈입니다.
완성해서 돌린 결과를 한 줄로 요약하면, 안정적으로 우상향하는 곡선은 못 만들었습니다. 숫자는 영상 결과 구간(10:22)에서 화면 그대로 보시는 게 정확합니다. 여기에 옮겨 적으면 제가 유리한 구간만 고르게 되니까요. 대신 패턴은 적어둘 만합니다. 손익이 진입 신호의 질보다 비용 쪽에 훨씬 민감했고, 여러 쌍이 동시에 같은 방향으로 물리는 구간이 반복해서 나왔습니다. 15쌍으로 분산했다고 생각했는데 실제로는 15개의 독립된 베팅이 아니었던 거예요. 시장이 위험 회피로 돌면 알트가 전부 BTC 대비 약해지고, BTC 롱 / 알트 숏으로 짜인 쌍들이 한꺼번에 이깁니다. 반대면 한꺼번에 집니다. 그건 시장중립이 아니라 알트 베타에 건 커다란 포지션 하나입니다.
왜 이게 실전에서 깨지는가 (12:02)
- 코인 페어에는 관계를 붙잡아주는 실물 고리가 없습니다. 주식 페어는 같은 업종·같은 원자재라는 이유로 벌어진 차이가 좁혀지지만, 코인에서 그 이유는 '같은 위험자산 취급'뿐입니다. 취급이 바뀌면 관계도 사라지고, 공적분 검정은 그걸 몇 달 뒤에야 알려줍니다.
- 공적분은 표본 안의 성질입니다. 지난 구간에 그랬다는 말이지 다음 구간에도 그렇다는 약속이 아닙니다. 조합 수가 많을수록 우연 통과가 늘어나니 단일 구간 p값 하나로 쌍을 확정하면 안 됩니다. 검정 구간을 몇 주만 옮겨도 통과·탈락이 뒤집히는 쌍이 흔했습니다.
- 칼만 필터의 delta는 양날입니다. 크게 잡으면 β가 시장을 빨리 따라가지만 노이즈까지 따라가면서 스프레드가 자꾸 0 근처로 눌리고 진입 신호 자체가 사라집니다. 작게 잡으면 관계가 이미 깨진 뒤에도 옛날 β로 계속 진입합니다. 이 값을 성과 보면서 맞추기 시작하면 그 순간부터 백테스트는 의미가 없어집니다.
- 트레일링 스탑은 평균회귀와 전제가 반대입니다. 되돌림을 먹으려는 전략에 되돌림을 손실로 처리하는 장치를 붙이면, 이기는 트레이드를 제일 좋은 자리에서 끊게 됩니다. 부품을 붙이기 전에 그 부품이 어느 가정 위에서 만들어졌는지 먼저 봤어야 했습니다.
- 15쌍은 15개의 독립 베팅이 아닙니다. 코인은 위험 선호가 바뀔 때 전부 같이 움직여서, 분산했다고 믿는 순간 상관이 1에 가까운 포지션 하나를 크게 들고 있게 됩니다. 쌍 수를 늘리는 것보다 서로 다른 이유로 움직이는 쌍을 찾는 게 먼저입니다.
- 비용이 손익보다 큽니다. 페어 하나를 여닫을 때마다 다리가 둘이라 수수료와 슬리피지가 두 배로 붙습니다. 무기한 선물이면 두 다리의 펀딩이 순액으로 남는데, 유리하게 붙는 날도 있지만 방향과 크기를 제가 정할 수 없는 변동 항목입니다. 평균회귀는 원래 건당 이익이 얇은 전략이라 이 층이 얹히면 기대값이 먼저 사라집니다. 백테스트에서 비용 가정만 조금 올려도 결과가 뒤집힌다면 그건 전략이 아니라 가정을 검증한 겁니다.
- 이 글과 배포 코드는 구조를 설명하려고 공개하는 교육·연구 자료입니다. 특정 종목이나 매매를 권하지 않습니다. 저는 이 구조로 원하는 결과를 못 냈고, 그 기록을 그대로 남기는 게 이 글의 목적입니다. 코드는 제 결론을 믿으라고 올리는 게 아니라 조건을 바꿔가며 직접 깨뜨려 보라고 올리는 겁니다.
첨부파일
영상에 사용된 소스코드와 설명서입니다. 로그인 없이 받을 수 있습니다.
롱숏 봇 소스코드 (공적분 + 칼만 필터)페어 후보 스크리닝 결과개발 기록과 뺐다 넣은 로직 목록