AlgoLab Blog · 퀀트·시스템 트레이딩 · 2026

페어트레이딩 짝 고르기 — 상관계수가 아니라 공적분

퀀트 · 방법론 2026-08-15 · 약 9분 읽기 · 알고랩 AlgoLab
한 줄 요약 페어트레이딩의 짝은 상관계수가 아니라 공적분으로 고릅니다. 파이썬에서는 statsmodels.tsa.stattools.coint(y0, y1)(coint_t, pvalue, crit_value)를 돌려주고, 귀무가설이 "공적분 없음"이라 p값이 작아야 짝의 근거가 됩니다. 다만 p값 하나로 고르면 반드시 실패합니다. 종목 500개면 짝이 124,750개라, 진짜 관계가 하나도 없어도 유의수준 0.05에서 기대상 6,237개가 그냥 통과합니다. 그래서 실무 순서는 ① 경제적 근거로 후보 축소 → ② 공적분 검정 → ③ OLS 헤지비율 → ④ 반감기로 보유기간 상한 → ⑤ 검정 기간 밖에서 재확인입니다.

"같이 움직이는 두 종목을 찾아서, 하나가 앞서 나가면 그걸 팔고 뒤처진 걸 사면 되는 거 아닌가." 이 이해 자체는 틀리지 않았습니다. 문제는 "같이 움직인다"를 무엇으로 측정하느냐에서 대부분 갈린다는 점입니다. 이 글은 짝을 고르는 절차와 그 절차가 어디서 무너지는지를 코드와 숫자로 정리합니다 (전략 30가지 카탈로그의 한 항목을 파고드는 글입니다).

먼저 분명히 해 둘 것. 이 글은 정량적 방법론 설명이며 특정 종목·조합의 추천이 아닙니다. 공적분·평균회귀는 학술적 통계 개념이고, 과거 데이터에서 성립한 관계가 앞으로도 유지된다는 보장은 없습니다. 수익률이나 시장 방향을 예측하지 않으며, 투자 판단과 그 결과는 투자자 본인의 책임입니다.

이 글의 순서

  1. 상관계수 0.95인데 페어가 안 되는 경우
  2. 1단계 — 공적분 검정 코드
  3. 2단계 — OLS로 헤지비율 구하기
  4. 3단계 — Z-score 진입·청산과 룩어헤드
  5. 4단계 — 반감기로 보유기간 상한 정하기
  6. 124,750쌍 — 다중검정 함정
  7. 집행 — 두 다리 중 한쪽만 체결될 때
  8. 자주 묻는 질문

1. 상관계수 0.95인데 페어가 안 되는 경우

상관계수는 "오늘 같은 방향으로 움직였는가"를 봅니다. 공적분은 "벌어진 가격 차이가 다시 좁혀지는가"를 봅니다. 페어트레이딩이 돈을 거는 대상은 후자입니다.

같은 업종의 두 종목 A와 B가 매일 나란히 오르내리면 일간 수익률 상관계수는 높게 나옵니다. 그런데 A가 3년에 걸쳐 꾸준히 B보다 앞서 나갔다면, 둘의 가격 차이는 벌어지기만 하고 한 번도 되돌아오지 않습니다. 여기에 "벌어졌으니 좁혀질 것"으로 진입하면 추세를 정면으로 거스르는 포지션이 됩니다.

2. 1단계 — 공적분 검정 코드

파이썬 statsmodelscoint가 표준 도구입니다. 시그니처와 반환값은 공식 문서 기준으로 아래와 같고, 귀무가설은 "공적분이 없다"입니다. 즉 p값이 작을수록 공적분이 없다는 가정을 기각할 근거가 생깁니다.

import pandas as pd
import numpy as np
from pykrx import stock
from statsmodels.tsa.stattools import coint

# 일봉 종가 — 데이터 소스는 pykrx / FinanceDataReader / KIS API 중 선택
def close_series(code, start, end):
    df = stock.get_market_ohlcv(start, end, code)
    return df["종가"].astype(float)

a = close_series("<종목A코드>", "20220101", "20251231")
b = close_series("<종목B코드>", "20220101", "20251231")

px = pd.concat([a, b], axis=1, keys=["A", "B"]).dropna()

# coint(y0, y1, trend='c', method='aeg', maxlag=None, autolag='aic')
coint_t, pvalue, crit = coint(px["A"], px["B"])

print(f"검정통계량 {coint_t:.3f}")
print(f"p값        {pvalue:.4f}")
print(f"임계값     1%={crit[0]:.3f}  5%={crit[1]:.3f}  10%={crit[2]:.3f}")
print("상관계수(일간수익률)",
      px.pct_change().dropna().corr().iloc[0, 1].round(3))

출력은 이런 모양입니다. 상관계수와 p값이 따로 논다는 것을 눈으로 확인하는 것이 이 단계의 목적입니다.

검정통계량 -3.412
p값        0.0387
임계값     1%=-3.918  5%=-3.351  10%=-3.048
상관계수(일간수익률) 0.612

데이터를 어디서 받을지는 별개 문제입니다. pykrx는 KRX 공시 데이터를, FinanceDataReader는 폭넓은 종목군을 다루고, 한국투자증권 KIS API로 직접 받을 수도 있습니다. 세 소스의 차이와 수정주가 처리는 따로 정리해 뒀는데, 페어트레이딩에서는 수정주가 처리가 특히 중요합니다. 액면분할이나 증자를 반영하지 않은 가격으로 검정하면 실제로는 없는 급격한 괴리가 데이터에 생겨 검정 결과가 통째로 흔들립니다. 한쪽 종목만 분할 이력이 있으면 스프레드가 그 시점에 계단처럼 꺾이는데, 공적분 검정은 그 계단을 "돌아오지 않는 괴리"로 읽어 멀쩡한 짝을 탈락시킵니다.

실거래 봇을 KIS API로 돌릴 계획이라면, 검정에 쓰는 가격도 한국투자증권에서 직접 받아 두는 편이 낫습니다. 백테스트가 본 값과 봇이 보는 값이 같은 출처가 되기 때문입니다. 국내주식 기간별시세는 tr_idFHKST03010100입니다.

import requests

BASE = "https://openapi.koreainvestment.com:9443"
PATH = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"

def kis_daily_close(code, d1, d2, access_token):
    res = requests.get(BASE + PATH, headers={
        "authorization": f"Bearer {access_token}",
        "appkey":    APP_KEY,
        "appsecret": APP_SECRET,
        "tr_id":     "FHKST03010100",   # 국내주식기간별시세(일/주/월/년)
        "custtype":  "P",
    }, params={
        "FID_COND_MRKT_DIV_CODE": "J",  # J = 주식
        "FID_INPUT_ISCD":  code,
        "FID_INPUT_DATE_1": d1,
        "FID_INPUT_DATE_2": d2,
        "FID_PERIOD_DIV_CODE": "D",     # D=일 W=주 M=월 Y=년
        "FID_ORG_ADJ_PRC": "0",         # 수정주가/원주가 구분 — 반드시 명시
    }, timeout=5)
    rows = res.json()["output2"]
    s = pd.Series({r["stck_bsop_date"]: float(r["stck_clpr"]) for r in rows})
    return s.sort_index()

FID_ORG_ADJ_PRC를 기본값에 맡기지 말고 명시적으로 넣으세요. pykrxadjusted 인자로, FinanceDataReader는 내부 소스에 따라, KIS API는 이 값으로 각각 수정주가 여부가 갈립니다. 세 소스를 섞어 쓰면서 이 설정만 안 맞추면 같은 종목의 스프레드가 소스마다 달라집니다. 한 번에 받아올 수 있는 봉 수에는 제한이 있으므로, 수년치가 필요하면 기간을 나눠 여러 번 호출하면서 초당 호출 제한을 함께 고려해야 합니다. 각 값의 의미와 기본값은 버전·정책에 따라 바뀔 수 있으니 공식 문서 기준으로 확인하시기 바랍니다.

3. 2단계 — OLS로 헤지비율 구하기

짝을 찾았다면 "A 1주에 B 몇 주"를 정해야 합니다. 이 비율이 헤지비율입니다. 1대1로 잡는 실수가 흔한데, 가격대가 다른 두 종목을 같은 수량으로 잡으면 비싼 쪽의 움직임이 포지션을 지배해서 애초에 헤지가 아닙니다.

import statsmodels.api as sm

X = sm.add_constant(px["B"])              # 상수항 추가
model = sm.OLS(px["A"], X).fit()
beta = model.params["B"]                  # 헤지비율

spread = px["A"] - beta * px["B"]         # 스프레드
print("헤지비율 beta =", round(beta, 4))
print("스프레드 평균 =", round(spread.mean(), 1),
      "표준편차 =", round(spread.std(), 1))

beta가 1.8이면 A 1주에 B 1.8주가 대응합니다. 실제 주문에서는 정수 주로 반올림해야 하므로 잔차가 남습니다. 자본이 작을수록 이 반올림 오차가 커지는데, A를 10주 살 자본밖에 없으면 헤지 정밀도는 이미 10% 단위로 뭉개져 있는 것입니다. 페어트레이딩이 소액에서 잘 안 도는 구조적 이유가 여기에 있습니다.

4. 3단계 — Z-score 진입·청산과 룩어헤드

스프레드를 표준화한 값이 Z-score입니다. Z가 +2를 넘으면 A가 과하게 앞선 것, −2를 밑돌면 뒤처진 것으로 보고 0 근처로 돌아올 때 청산하는 것이 기본형입니다.

WIN = 60                                   # 롤링 윈도우(거래일)

mu   = spread.rolling(WIN).mean()
sd   = spread.rolling(WIN).std()
z    = (spread - mu) / sd

pos = pd.Series(0, index=z.index)
pos[z >  2.0] = -1        # 스프레드 축소에 베팅: A 매도 / B 매수
pos[z < -2.0] =  1        # 반대 방향
pos[z.abs() < 0.5] = 0    # 청산
pos = pos.replace(0, np.nan).ffill().fillna(0)

# 신호는 당일 종가로 만들고 집행은 다음 날 → 1봉 시프트 필수
pos = pos.shift(1)

마지막 shift(1)을 빼면 백테스트가 통째로 거짓말이 됩니다. 종가로 계산한 Z-score를 그 종가에 체결했다고 처리하면 미래를 보고 주문한 셈이 됩니다. 전체 구간 평균·표준편차로 표준화하는 것도 같은 종류의 오류입니다 (그 구간에는 아직 오지 않은 미래가 들어 있습니다). 평균회귀 전략은 이 실수 하나로 성과 곡선이 극적으로 좋아지기 때문에 "결과가 너무 좋으면 룩어헤드부터 의심"하는 것이 순서입니다.

+2σ 0 −2σ 진입 청산 진입 청산 진입
Z-score 밴드 이탈에서 진입, 0 근처 복귀에서 청산 — 도식이며 실제 성과 예시가 아니다

5. 4단계 — 반감기로 보유기간 상한 정하기

"언제 청산하나"보다 먼저 답해야 할 것은 "언제까지 기다릴 것인가"입니다. 평균회귀 속도를 수치로 만든 것이 반감기(half-life)입니다. 스프레드의 일간 변화량을 전날 스프레드에 회귀해 계수 λ를 얻고, -ln(2)/λ로 계산합니다.

s      = spread.dropna()
lag    = s.shift(1).dropna()
delta  = (s - s.shift(1)).dropna()
common = lag.index.intersection(delta.index)

res    = sm.OLS(delta.loc[common], sm.add_constant(lag.loc[common])).fit()
lam    = res.params.iloc[1]

if lam >= 0:
    print("평균회귀 없음 — 이 짝은 제외")
else:
    print("반감기 =", round(-np.log(2) / lam, 1), "거래일")
반감기의미실무 판단
λ ≥ 0되돌아오지 않음후보에서 제외
1~3거래일너무 빠름수수료·슬리피지가 구조를 먹음
5~20거래일다루기 쉬운 구간보유 상한을 반감기의 2~3배로
40거래일 이상자금이 오래 묶임기회비용·이벤트 위험 급증

반감기가 있으면 "Z가 안 돌아오면 무기한 보유"라는 최악의 기본값을 없앨 수 있습니다. 보유 상한을 넘기면 손실이든 아니든 정리한다는 규칙이 있어야 봇의 안전장치가 의미를 갖습니다.

6. 124,750쌍 — 다중검정 함정

여기가 페어트레이딩에서 가장 많이 무너지는 지점입니다. 종목 500개로 만들 수 있는 짝의 수는 500 × 499 ÷ 2 = 124,750개입니다. 유의수준 0.05는 "공적분이 없는 짝도 100번에 5번은 통과시킨다"는 뜻입니다. 그러니 진짜 관계가 하나도 없어도 기대상 124,750 × 0.05 ≈ 6,237쌍이 p값 0.05 미만으로 나옵니다.

종목 수가능한 짝α=0.05에서 우연 통과 기대
501,225약 61쌍
1004,950약 248쌍
20019,900약 995쌍
500124,750약 6,237쌍

이 목록에서 "p값이 가장 작은 상위 10개"를 골라 백테스트하면 성과는 반드시 좋게 나옵니다. 좋은 짝을 찾은 게 아니라 우연히 잘 맞은 과거를 골라낸 것이기 때문입니다.

완화 장치 네 가지

백테스트 도구는 뭘 써도 됩니다. 스프레드와 포지션을 pandas로 계산해 수익 곡선만 그려도 검증은 됩니다. backtrader·vectorbt처럼 프레임워크를 쓰면 두 다리를 별도 자산으로 다루기 편하고, 성과지표는 quantstats·pyfolio로 뽑습니다. 선택 기준은 파이썬 백테스팅 라이브러리 비교에 정리돼 있습니다. 다만 어떤 도구를 쓰든 거래비용을 넣지 않은 페어 백테스트는 의미가 없습니다. 진입 1회에 매수·매도 두 다리, 청산에 또 두 다리라 왕복 4건의 비용이 붙습니다.

7. 집행 — 두 다리 중 한쪽만 체결될 때

백테스트가 끝나면 실제 주문이 남습니다. 여기서 페어는 단일 종목 전략과 결정적으로 다릅니다. 두 다리가 동시에 성립해야 헤지인데, 주문은 동시에 체결되지 않습니다.

한국투자증권 KIS API로 국내주식을 주문하면 매수는 tr_id TTTC0802U, 매도는 TTTC0801U로 각각 나갑니다. 두 번의 호출이고, 한쪽만 붙을 수 있습니다. A는 체결됐는데 B가 미체결로 남으면 그 순간 포지션은 헤지가 아니라 단방향 베팅입니다. 봇은 이 상태를 인지조차 못 하는 경우가 많습니다.

그래서 주문 다음에 체결 확인이 반드시 붙어야 합니다. KIS API에서는 주식일별주문체결조회(TTTC0081R)로 rmn_qty·tot_ccld_qty를 확인합니다. KIS 주문체결조회 사용법을 참고해 "한쪽만 체결된 상태"를 명시적으로 처리하는 분기를 넣으세요 — 즉시 반대 다리를 시장가로 채우든, 체결된 쪽을 취소·청산하든 규칙이 있어야 합니다.

코드로 옮기면 이런 골격입니다. 주문 두 건을 내고 돌아온 ODNO 두 개를 반드시 붙잡아 둔 뒤, 잠시 후 체결 상태를 확인해 한쪽만 붙었으면 처리 규칙을 태우는 구조입니다.

ORDER = "/uapi/domestic-stock/v1/trading/order-cash"

def kis_order(code, qty, side, access_token):
    """side: 'buy' -> TTTC0802U / 'sell' -> TTTC0801U"""
    tr = "TTTC0802U" if side == "buy" else "TTTC0801U"
    body = {"CANO": CANO, "ACNT_PRDT_CD": ACNT_PRDT_CD,
            "PDNO": code, "ORD_DVSN": "01",   # 01 = 시장가
            "ORD_QTY": str(qty), "ORD_UNPR": "0"}
    res = requests.post(BASE + ORDER, json=body, headers={
        "authorization": f"Bearer {access_token}",
        "appkey": APP_KEY, "appsecret": APP_SECRET,
        "tr_id": tr, "custtype": "P",
        "hashkey": make_hashkey(body),        # 주문에는 hashkey 필요
    }, timeout=5).json()
    if res["rt_cd"] != "0":
        raise RuntimeError(res["msg_cd"], res["msg1"])
    return res["output"]["ODNO"]              # 주문번호 보관

leg_a = kis_order(CODE_A, qty_a, "buy",  token)
leg_b = kis_order(CODE_B, qty_b, "sell", token)

time.sleep(2)
filled = check_filled([leg_a, leg_b])         # TTTC0081R 로 조회
if filled[leg_a] != filled[leg_b]:
    handle_one_legged(leg_a, leg_b, filled)   # 규칙을 여기에

ORD_DVSN01(시장가)로 둔 것은 의도적입니다. 지정가로 두 다리를 걸면 한쪽만 체결될 확률이 그만큼 올라가기 때문입니다. 대신 슬리피지를 감수하는 선택이라, 어느 쪽이 나은지는 반감기가 짧을수록 시장가 쪽으로 기웁니다. 호가가 얇은 종목이라면 체결·슬리피지 관리 기준을 먼저 정하고 들어가세요.

증권사 선택도 영향을 줍니다. 두 다리를 짧은 간격으로 내야 하므로 초당 호출 제한과 주문 API의 응답 속도가 그대로 슬리피지로 넘어옵니다. 국내 증권사 API 비교에서 키움·KIS·LS증권의 차이를 확인해 두면 좋습니다.

국내 시장 특유의 제약 세 가지

검정부터 집행까지 붙은 형태로 만들고 싶다면

후보 스크리닝, 공적분 검정, 헤지비율 갱신, 두 다리 주문과 체결 확인까지 하나로 도는 상태로 제작해 드립니다. 쓰시는 증권사와 데이터 소스만 알려 주시면 됩니다.

무료로 상담해 보기 →

8. 자주 묻는 질문

짝을 얼마나 자주 다시 골라야 하나요?

정답은 없지만 반감기와 검정 윈도우 길이가 기준이 됩니다. 2~3년 데이터로 검정했다면 분기 단위 재선정이 흔한 선택입니다. 매일 다시 고르면 선정 자체가 과최적화가 되고, 몇 년씩 고정하면 관계가 깨진 뒤에도 계속 들고 갑니다. 중요한 것은 주기 그 자체보다 재선정 규칙을 미리 정해 두고 지키는 것입니다.

코인에도 같은 방법을 쓸 수 있나요?

통계 절차는 그대로 적용됩니다. 다만 24시간 시장이라 "거래일" 대신 시간·분 단위로 윈도우와 반감기를 다시 정의해야 하고, 상장폐지나 페어 소멸이 잦아 유니버스 필터의 비중이 훨씬 큽니다.

성과는 무엇으로 평가하나요?

누적 수익률만 보면 안 됩니다. MDD(최대낙폭)와 샤프지수, 평균 보유기간, 진입 횟수, 그리고 거래비용 반영 전후 차이를 같이 봅니다. 페어는 진입 횟수가 많아 비용 반영 후에 성과가 크게 달라지는 유형입니다. 워크포워드로 구간을 나눠 평가하지 않은 숫자는 참고치일 뿐입니다. 어떤 지표를 어떻게 읽는지는 백테스트 리포트 읽는 법에 정리해 뒀습니다.

이 방법이 지금도 통하나요?

답하지 않겠습니다. 그건 예측이고, 이 글은 예측을 하지 않습니다. 말할 수 있는 것은 공적분·평균회귀가 오래된 학술 개념이고, 절차를 지키지 않으면 검정 결과가 우연을 골라낸 것에 불과해진다는 사실까지입니다. 실제 자금을 넣기 전에 모의투자와 소액 단계를 반드시 거치시고, 제도·수수료·세금 관련 사항은 증권사와 거래소의 공식 자료로 직접 확인하시기 바랍니다.

전략을 코드로 옮기는 일이 막힌다면

검정·백테스트·집행까지 실제로 도는 상태로 만들어 드립니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기