멀티팩터 알파 만들기 — z-score · 분위 시그널 · 백테스트를 pandas 한 노트북에서
Summary
앞 글 에서는 PER · RSI · MACD · Sharpe 같은 팩터들을 하나씩 정의식과 함께 pandas 로 풀어봤어요. 함수 단위로 동작은 하지만, 사실 그 다음이 더 어려워요. “서로 단위가 다른 팩터들을 어떻게 한 점수로 합칠 것인가”, “그 점수로 만든 시그널을 어떻게 백테스트해야 미래정보가 새지 않을까” — 이 부분이요.
이번 글에서는 그 흐름을 한 번에 정리합니다. 단일 종목 시계열에서 잠깐 벗어나서, 여러 종목 패널 데이터 위에서 동작하는 멀티팩터 알파를 만들어볼게요.
💡 이 글에서 다루는 것
- 팩터 표준화 — z-score, winsorize (왜 그냥 평균이 아닌가)
- 팩터 결합 — 등가중 알파, 가중치 튜닝의 함정
- 시그널 — 분위(decile) 기반 롱숏, 단일 임계값과 뭐가 다른가
- 포지션 → 수익률 —
.shift(1)로 lag, look-ahead bias 방지- 평가 — 누적수익률 · MDD · Sharpe · 회전율, 거래비용 보정
- 팩터 자체 평가 — IC(Information Coefficient)
수식은 의미가 모호한 곳에만 박고, 나머지는 pandas 코드로 바로 갑니다.
0. 공통 환경 — 단일 종목에서 종목 패널로
앞 글의 함수들은 단일 시리즈(pd.Series)를 받았어요. 멀티팩터 알파는 원래 여러 종목을 가로지르는 비교 라서, 패널 데이터로 한 단계 확장합니다.
import numpy as np
import pandas as pd
# panel: long-format DataFrame
# index: RangeIndex
# columns: ['date', 'ticker', 'close', 'volume', 'per', 'roe', ...]
panel = pd.read_parquet("ohlcv_with_fundamentals.parquet")
panel["date"] = pd.to_datetime(panel["date"])
panel = panel.sort_values(["ticker", "date"]).reset_index(drop=True)
자기 데이터(증권사 CSV, FDR, KRX 다운로드 등)가 없는 분들 위해 — 아래 본문의 모든 print 출력은 시드를 박은 합성 패널(5 종목 × 80 영업일) 위에서 돌린 결과예요. 따라할 때는 위 parquet 로드 대신 이 블록을 한 번 실행해두면 같은 숫자가 나옵니다.
np.random.seed(42)
tickers = ["A", "B", "C", "D", "E"]
dates = pd.date_range("2025-09-01", periods=80, freq="B")
records = []
for tk in tickers:
base = np.random.uniform(80, 200)
drift, vol = np.random.uniform(-0.0005, 0.0015), np.random.uniform(0.012, 0.022)
prices = base * np.exp(np.cumsum(np.random.normal(drift, vol, len(dates))))
per_seed = np.random.uniform(7, 35)
roe_seed = np.random.uniform(0.05, 0.22)
for d, p in zip(dates, prices):
records.append({
"date": d, "ticker": tk, "close": p,
"volume": np.random.randint(10_000, 200_000),
"per": per_seed + np.random.normal(0, 0.5),
"roe": roe_seed + np.random.normal(0, 0.002),
"rsi14": np.random.uniform(20, 80),
"macd_hist": np.random.normal(0, 0.5),
"bb_w": np.random.uniform(0.05, 0.25),
})
panel = pd.DataFrame(records).sort_values(["ticker", "date"]).reset_index(drop=True)
작업 편의를 위해 wide pivot 도 같이 만들어둡니다. 가격 팩터(RSI, MACD 등)는 ticker 단위 시계열로 굴리고, 횡단면(cross-section) 비교는 (date, ticker) MultiIndex 위에서 한다는 두 단계예요.
def to_wide(panel: pd.DataFrame, col: str) -> pd.DataFrame:
return panel.pivot(index="date", columns="ticker", values=col)
5 종목 × 80 영업일 합성 패널로 한 번 돌려봅니다.
close_wide = to_wide(panel, "close")
print(close_wide.shape)
print(close_wide.tail(3).round(2))
(80, 5)
ticker A B C D E
date
2025-12-17 132.69 217.68 172.61 100.34 179.18
2025-12-18 132.34 220.30 172.76 100.12 176.10
2025-12-19 131.43 211.37 169.30 100.74 177.88
(80 rows, 5 columns) — long 패널이 시점 × 종목 매트릭스로 펼쳐졌어요. 인덱스가 날짜, 컬럼이 ticker 라 두 축에서 바로 슬라이싱 가능합니다.
⚠️ 패널이 long 인지 wide 인지 헷갈리면 백테스트 전체가 망가져요. 함수 시그니처에 어느 쪽을 기대하는지 주석으로 박아두는 걸 추천드립니다.
1. 팩터 표준화 — 왜 z-score 인가
정의
종목 $i$, 시점 $t$ 의 팩터 값을 $x_{i,t}$ 라 할 때, 횡단면(cross-sectional) z-score 는
\[z_{i,t} = \frac{x_{i,t} - \mu_t}{\sigma_t}, \qquad \mu_t = \frac{1}{N_t}\sum_{j=1}^{N_t} x_{j,t}, \quad \sigma_t = \sqrt{\frac{1}{N_t}\sum_{j=1}^{N_t}(x_{j,t} - \mu_t)^2}\]여기서 $N_t$ 는 $t$ 시점에 데이터가 있는 종목 수.
💡 기호 풀기
- 첨자 $i$ 는 종목, $t$ 는 시점. $x_{i,t}$ 는 “종목 i 의 시점 t 팩터 값”.
- 평균/표준편차 모두 같은 시점 $t$ 안에서 종목 $j$ 에 대해 계산해요. 이게 “횡단면” 의 뜻이에요.
- 시계열 z-score(같은 종목의 과거 평균/표준편차로 나눔) 와는 의미가 달라요. 종목 간 상대 순위 가 알고 싶을 땐 횡단면.
왜 이렇게 계산하나
- 단위가 다른 팩터들을 같이 더할 수 있게 만드는 게 핵심 목적이에요. PER 는 보통 5~30, RSI 는 0~100, MACD 히스토그램은 절대값이 종목마다 천차만별. 그냥 더하면 큰 스케일의 팩터가 지배해버려요.
- 횡단면 기준으로 정규화 하는 이유 — “이 종목이 같은 날 다른 종목 대비 얼마나 위/아래에 있나” 가 본질이라서. 시계열 z-score 는 “이 종목이 자기 과거 대비 얼마나 위/아래” 라 의미가 달라집니다.
- 꼬리값(outlier) 처리는 z-score 전에 해야 해요. 안 하면 한 종목의 비정상값이 그날 전체 $\mu, \sigma$ 를 휘둘러서 다른 종목 z 가 다 망가집니다.
Python
먼저 winsorize 한 다음 z-score 를 매기는 게 표준 흐름이에요.
def winsorize(s: pd.Series, lower=0.01, upper=0.99) -> pd.Series:
lo, hi = s.quantile(lower), s.quantile(upper)
return s.clip(lower=lo, upper=hi)
한 단면(같은 날짜의 5 종목 PER) 에 winsorize(0.1, 0.9) 를 깔아보면 어떻게 양쪽 꼬리가 잘리는지 한눈에 보여요.
day0 = panel[panel["date"] == dates[0]].set_index("ticker")["per"]
print("원본:")
print(day0.round(2))
print("winsorize(0.1, 0.9):")
print(winsorize(day0, 0.1, 0.9).round(2))
원본:
ticker
A 11.21
B 8.13
C 17.53
D 14.37
E 14.16
winsorize(0.1, 0.9):
ticker
A 11.21
B 9.36
C 16.27
D 14.37
E 14.16
원본 최저값 B(8.13)는 10% 분위(9.36)로, 최고값 C(17.53)는 90% 분위(16.27)로 잘려 들어왔어요. 가운데 3개는 그대로.
이걸 패널 전체로 확장하면 — 매 시점 단면에서 winsorize → z-score 까지 한 함수로:
def cs_zscore(df_long: pd.DataFrame, col: str,
winsor=(0.01, 0.99)) -> pd.Series:
"""date 별로 winsorize → z-score 까지 한 번에."""
def _per_day(g: pd.Series) -> pd.Series:
g = winsorize(g, *winsor)
return (g - g.mean()) / g.std(ddof=0)
return df_long.groupby("date")[col].transform(_per_day)
panel["z_per"] = cs_zscore(panel, "per")
print(panel[["date", "ticker", "per", "z_per"]].tail(5).round(3).to_string(index=False))
date ticker per z_per
2025-12-15 E 14.565 0.451
2025-12-16 E 13.922 0.345
2025-12-17 E 14.656 0.561
2025-12-18 E 14.534 0.621
2025-12-19 E 13.735 0.198
같은 종목 E 라도 그날 단면의 평균/표준편차에 따라 z-score 가 0.2 ~ 0.6 사이를 오가요. 절대 PER 가 비슷해도 같은 날 다른 종목들과 비교한 상대 위치 가 매일 달라지는 게 횡단면 z-score 의 본질입니다.
여러 팩터를 한 번에 z-score 화 할 때는 컬럼 루프.
factor_cols = ["per", "roe", "rsi14", "macd_hist", "bb_w"]
for c in factor_cols:
panel[f"z_{c}"] = cs_zscore(panel, c)
해석 / 함정
- 부호 정렬을 안 맞추면 알파가 상쇄돼요. PER 는 낮을수록 좋고(저PER = 가치), ROE 는 높을수록 좋아요. 단순히 z-score 더하면 두 팩터가 반대 방향으로 작용. PER 같은 “낮을수록 좋은” 팩터는 부호를 뒤집어서
z_per_inv = -z_per로 만들어두는 게 안전. - 결측 처리 — 어떤 종목에 PER 가 없으면 그 종목은 그날 z 가 NaN. 결합 단계에서
np.nan이 한 칸이라도 있으면 합산이 NaN 으로 떨어지니까, 보통 이용 가능한 팩터만 평균 내는 방식을 씁니다.
2. 팩터 결합 — 등가중 알파부터
정의
가용 팩터 집합을 $K$, 종목 $i$ 의 시점 $t$ 알파를
\[A_{i,t} = \frac{1}{|K_{i,t}|} \sum_{k \in K_{i,t}} s_k \cdot z_{k,i,t}\]로 정의해요. $s_k \in {+1, -1}$ 는 팩터 $k$ 의 방향성(저PER 좋아하면 $-1$), $K_{i,t}$ 는 그 종목/시점에서 NaN 이 아닌 팩터 집합.
왜 이렇게 계산하나
- 등가중부터 시작 하는 이유 — 가중치를 데이터에서 학습시키면 그날 그날 잘 맞는 가중치로 빠르게 과적합돼요. 실무 백테스트의 첫 줄은 거의 항상 등가중부터예요. 베이스라인 없이 가중치 튜닝부터 가는 게 흔한 실수.
- NaN 처리는 평균에서 —
sum / |K|가 아니라mean(skipna=True)로 풀면 가용 팩터만으로 자동 평균이 돼서, 결측 종목도 점수가 살아납니다. - 사후적으로 가중치를 튜닝(예: 회귀로 IC 최대화) 하더라도 그 결과를 out-of-sample — 가중치 학습에 쓰지 않은 별도 구간(보통 학습 구간보다 나중 의 데이터) — 에서 다시 검증해야 의미가 있어요. 이건 후속 글 주제.
Python
# 방향성 사전 정의 (낮을수록 좋은 팩터는 -1)
signs = {"per": -1, "roe": +1, "rsi14": -1, "macd_hist": +1, "bb_w": -1}
z_cols = [f"z_{c}" for c in signs]
for c, sgn in signs.items():
panel[f"a_{c}"] = sgn * panel[f"z_{c}"]
panel["alpha"] = panel[[f"a_{c}" for c in signs]].mean(axis=1, skipna=True)
해석 / 함정
- 팩터 간 상관관계가 너무 높으면 등가중이 그 클러스터에 가중치가 쏠리는 효과를 만들어요. 사전에 팩터 상관행렬을 한 번 그려보고, 0.7 이상 짝은 하나를 빼거나 그룹 안에서 평균 후 그룹간 평균을 쓰는 게 안전합니다.
corr = panel.groupby("date")[z_cols].corr().groupby(level=1).mean()
같은 날 단면에서 페어와이즈 상관을 본 뒤 시점 평균으로 한 장. 0.7 이상이 보이면 후보 정리.
3. 분위 기반 시그널 — 단일 임계값과 뭐가 다른가
정의
매 시점 $t$ 에서 알파 $A_{i,t}$ 를 종목 기준 분위로 나누고, 상위 $q$ 분위는 롱, 하위 $q$ 분위는 숏.
\[w_{i,t} = \begin{cases} +1/N_{\text{top}}, & \text{rank}(A_{i,t}) \ge 1 - q \\ -1/N_{\text{bot}}, & \text{rank}(A_{i,t}) \le q \\ 0, & \text{otherwise} \end{cases}\]💡 기호 풀기
- $\text{rank}(A_{i,t})$ — 그날의 알파를 0~1 사이 분위로 매긴 값.
pct=True의 rank 와 같아요.- $q$ — 보통 0.1 (상하위 10%) 또는 0.2 (20%). 작을수록 진입 종목이 줄어 변동성↑.
- $w_{i,t}$ — 그날 종목 $i$ 에 할당하는 비중. 롱숏이면 절대값 합이 2(롱 1 + 숏 1)가 되게 정규화.
왜 이렇게 계산하나
- 단일 임계값(예: 알파 > 0.5) 은 알파 분포가 시점마다 흔들리면 어떤 날은 100 종목, 어떤 날은 2 종목이 잡혀버려요. 분위 기반은 매일 같은 비율 만 진입하니까 노출이 일정해집니다.
- 롱숏 동시 진입 은 시장 베타를 0 에 가깝게 만드는 효과 — 시장이 빠지면 롱은 빠지지만 숏이 벌어줘서 알파 자체의 효과만 분리해볼 수 있어요. 롱온리 백테스트는 시장이 좋으면 다 좋아 보이는 착시가 끼기 쉽습니다.
Python
def decile_signal(panel: pd.DataFrame, alpha_col: str = "alpha",
q: float = 0.1) -> pd.Series:
def _per_day(g: pd.Series) -> pd.Series:
pct = g.rank(pct=True, method="average")
w = pd.Series(0.0, index=g.index)
top = pct >= (1 - q)
bot = pct <= q
if top.any():
w.loc[top] = +1.0 / top.sum()
if bot.any():
w.loc[bot] = -1.0 / bot.sum()
return w
return panel.groupby("date")[alpha_col].transform(_per_day)
5 종목 panel 이라 q=0.1 이면 컷에 잡히는 게 없으니 q=0.2 로 돌립니다. 마지막 날 단면이 어떻게 분배되는지 보면:
panel["w"] = decile_signal(panel, "alpha", q=0.2)
last_day = panel[panel["date"] == dates[-1]][["ticker", "alpha", "w"]].round(3)
print(last_day.to_string(index=False))
ticker alpha w
A 0.620 0.5
B 0.428 0.5
C -0.041 0.0
D -0.299 0.0
E -0.708 -1.0
알파 상위 2종목(A/B)이 각 +0.5 로 롱(합 +1), 하위 1종목(E)이 -1.0 으로 숏(합 -1). 가운데 두 종목은 0 — 같은 날 합산하면 롱/숏 양쪽이 자동으로 +1 / -1 로 정규화돼요.
해석 / 함정
- 유동성 필터 가 빠지면 알파가 시총 작은 종목으로 몰려요. 실거래 불가능한 종목까지 잡히면 백테스트만 빛나고 실거래는 안 돌아갑니다. 보통 거래대금 하위 20% 같은 식으로 사전 컷.
- 섹터 중립 — 한 섹터에 쏠리는 게 알파가 아니라 섹터 베팅이 되어버리는 걸 막으려면, 섹터 안에서 분위를 매기는 게 안전해요.
groupby(["date", "sector"])로 한 단계 더 들어가면 됩니다.
4. 포지션 lag — Look-ahead bias 한 줄로 막기
정의
$t$ 시점에 계산한 알파/시그널은 $t$ 시점의 종가까지를 보고 만든 값이라, 다음 거래일 수익률 에 곱해야 미래정보가 새지 않는 백테스트가 됩니다.
\[r^{\text{strat}}_{t+1} = \sum_i w_{i,t} \cdot r_{i,t+1}\]왜 이렇게 계산하나
- $w_{i,t}$ 안에는 $P_{i,t}$ 가 들어가 있어요. 그 비중으로 같은 $t$ 의 수익률 $r_{i,t}$ 를 곱하면 종가가 시작과 끝 양쪽에 끼어서 사실상 “오늘 종가를 알고 오늘 종가에 진입” 한 셈이 됩니다. 이게 가장 흔한 미래정보 누수.
- 해결은 한 줄. 비중을 하루 lag.
Python
ret_wide = close_wide.pct_change() # 종목별 일간 단순 수익률
w_wide = panel.pivot(index="date", columns="ticker", values="w")
w_lag = w_wide.shift(1) # 🚨 한 줄로 lag
# 포트폴리오 일간 수익률
port_ret = (w_lag * ret_wide).sum(axis=1)
port_ret 은 index=date, 값은 각 날짜의 단일 수익률(스칼라)인 pd.Series 예요. 다음 섹션의 누적/MDD/Sharpe 가 모두 이 한 시리즈를 받아서 동작합니다.
펀더멘털 팩터를 쓰는 경우엔 lag 가 한 줄로 안 끝나요. 공시 가능 시점 기준 으로 ffill 해줘야 합니다.
# fundamentals: index=공시일, columns=ticker, values=per
per_panel = per_panel.reindex(close_wide.index).ffill()
결산일이 아니라 공시일 을 인덱스로 쓰는 게 핵심.
해석 / 함정
- 거래 시점을 종가로 두느냐 익일 시가로 두느냐 — 실무에서는 종가 진입(close-to-close) 가정이 흔하지만, 시가 진입(close-to-open + open-to-close 분리)이 더 현실적이에요. 시그널 발생일 종가에 진입 가정이면 위 한 줄로 OK, 시가 진입이면 시가 시리즈 따로 만들어서 곱.
- 거래정지/상장폐지일 의 수익률이 그대로 NaN 으로 박혀 있으면
(w_lag * ret)합산에서 그 종목만 0 으로 떨어져요. 의도된 동작이면 OK, 아니면 사전 마스킹.
5. 누적수익률과 MDD
정의
$V_0 = 1$ 부터 시작하는 누적가치(equity curve):
\[V_T = \prod_{t=1}^{T} (1 + r^{\text{strat}}_t)\]Max Drawdown (MDD):
\[\text{MDD}_T = \min_{t \le T}\left( \frac{V_t}{\max_{s \le t} V_s} - 1 \right)\]💡 기호 풀기
- $V_t$ — t 시점까지의 누적가치. 1 에서 시작해서 매일 (1+수익률) 을 곱해 나간 값.
- $\max_{s \le t} V_s$ — t 시점까지의 고점. running max.
- 분수 안: 현재가치 / 고점 → 1 보다 작으면 빠진 상태. -1 하면 음수가 손실폭.
- $\min$ — 그 손실폭의 최댓값(가장 큰 손실)을 잡는 거예요.
왜 이렇게 계산하나
- MDD 는 심리적 한계 를 보여줘요. 같은 Sharpe 라도 MDD -10% 와 -40% 는 운용 가능성이 전혀 다릅니다. -40% 빠지는 전략은 거의 실거래 못 가져가요.
- 누적은 곱셈, 평균은 합 — 단순 수익률로는 누적이 $\prod (1+r)$, 로그 수익률은 $\sum r$. 백테스트 보고서에서는 보통 단순 수익률 누적 곡선을 보여줍니다.
Python
def equity_curve(returns: pd.Series) -> pd.Series:
return (1 + returns.fillna(0)).cumprod()
def max_drawdown(returns: pd.Series) -> float:
v = equity_curve(returns)
peak = v.cummax()
dd = v / peak - 1
return dd.min()
언더워터 곡선(drawdown 시계열)도 같이 그리면 손실 회복 기간이 한눈에 보여요.
def drawdown_series(returns: pd.Series) -> pd.Series:
v = equity_curve(returns)
return v / v.cummax() - 1
위에서 만든 port_ret 으로 셋 다 돌려보면:
print("equity_curve tail:")
print(equity_curve(port_ret).round(4).tail(3))
print("max_drawdown:", round(max_drawdown(port_ret), 4))
print("drawdown_series tail:")
print(drawdown_series(port_ret).round(4).tail(3))
equity_curve tail:
date
2025-12-17 0.8856
2025-12-18 0.8744
2025-12-19 0.9113
dtype: float64
max_drawdown: -0.3113
drawdown_series tail:
date
2025-12-17 -0.1331
2025-12-18 -0.1441
2025-12-19 -0.1080
dtype: float64
이 시뮬레이션은 가짜 팩터라 누적가치(equity)가 0.91 까지 빠진 상태(< 1.0 → 손실 구간). MDD -31% 면 실거래는 불가능한 수준이지만, 함수가 받는/돌려주는 모양 을 확인하기엔 충분해요.
해석 / 함정
- 수익률 분포가 왜곡되면 MDD 추정이 낙관적 으로 나옵니다. 백테스트 기간에 큰 위기가 없었다면 MDD 는 작게 나오는 게 당연. 다른 기간으로 walk-forward (시간을 앞으로 한 칸씩 미루면서 “앞 구간으로 학습/튜닝 → 다음 구간에서 평가” 를 반복하는 방식) 해보거나 부트스트랩으로 MDD 분포를 같이 봐야 추정이 덜 낙관적으로 나와요.
6. 평가지표 한 묶음 — Sharpe · 회전율 · 거래비용
핵심 수식 모음
위에서 만든 port_ret 위에서, 앞 글의 Sharpe 정의를 그대로 가져와요.
회전율(turnover) 은 비중의 변화량으로 정의합니다.
\[\text{Turnover}_t = \frac{1}{2}\sum_i \left| w_{i,t} - w_{i,t-1} \right|\]거래비용 $c$ (편도) 가정 하의 순수익률.
\[r^{\text{net}}_t = r^{\text{strat}}_t - c \cdot 2 \cdot \text{Turnover}_t\]💡 기호 풀기
- 회전율 앞 $\tfrac{1}{2}$ — 한 종목을 늘리는 만큼 다른 종목을 줄이는 동전의 양면이라, 그 둘이 합쳐서 한 번의 거래량이 되거든요. 절대값 합을 2로 나누면 “진짜 거래량” 이 됩니다.
- $c$ — 편도 거래비용(수수료 + 슬리피지). 한국 주식이라면 보통 10~30bp 가정에서 출발.
- 곱하기 2 — 회전율이 편도라서 매수+매도 양쪽 비용을 다 깔려고 한 번 더.
Python
def sharpe(returns: pd.Series, rf_annual: float = 0.03,
periods_per_year: int = 252) -> float:
rf_daily = rf_annual / periods_per_year
excess = returns - rf_daily
return (excess.mean() / excess.std(ddof=1)) * np.sqrt(periods_per_year)
def turnover(weights: pd.DataFrame) -> pd.Series:
# weights: index=date, columns=ticker
delta = weights.fillna(0).diff().abs().sum(axis=1) / 2
return delta
def apply_cost(strat_ret: pd.Series, w: pd.DataFrame, cost_bps: float = 15) -> pd.Series:
to = turnover(w)
return strat_ret - (cost_bps / 1e4) * 2 * to
세 함수를 위 port_ret / w_wide 위에서 돌리면:
print("sharpe:", round(sharpe(port_ret), 3))
print("turnover tail:")
print(turnover(w_wide).round(3).tail(3))
print("avg turnover:", round(turnover(w_wide).mean(), 3))
print("apply_cost(cost_bps=15) tail:")
print(apply_cost(port_ret, w_wide, cost_bps=15).round(5).tail(3))
sharpe: -0.698
turnover tail:
date
2025-12-17 1.5
2025-12-18 1.0
2025-12-19 1.5
dtype: float64
avg turnover: 1.181
apply_cost(cost_bps=15) tail:
date
2025-12-17 0.00409
2025-12-18 -0.01571
2025-12-19 0.03771
dtype: float64
회전율이 평균 1.18 — 매일 비중의 100% 가까이 갈아치우는 셈이라 거래비용 페널티가 크게 깔립니다. apply_cost 가 돌려준 수익률은 원본보다 매일 0.35%p (= 2 * 15bp * 평균 turnover) 정도 깎인 셈.
전체 보고서 한 줄.
def report(strat_ret: pd.Series, w: pd.DataFrame, cost_bps: float = 15) -> pd.Series:
net = apply_cost(strat_ret, w, cost_bps=cost_bps)
return pd.Series({
"ann_return": (1 + net.mean()) ** 252 - 1,
"ann_vol": net.std(ddof=1) * np.sqrt(252),
"sharpe": sharpe(net),
"mdd": max_drawdown(net),
"turnover": turnover(w).mean(),
"hit_ratio": (net > 0).mean(),
})
print(report(port_ret, w_wide, cost_bps=15).round(4))
ann_return -0.6742
ann_vol 0.3706
sharpe -3.0997
mdd -0.3830
turnover 1.1812
hit_ratio 0.4375
dtype: float64
가짜 팩터로 굴린 거라 결과는 음수예요(연 -67%, Sharpe -3.1). 실데이터 백테스트는 이 6개 스칼라가 한 묶음으로 떨어지는 모양 그대로 받습니다.
해석 / 함정
- 회전율이 너무 낮으면 시그널이 거의 안 바뀐다는 뜻 — 슬로우 팩터(가치/퀄리티) 면 자연스럽고, RSI 같은 단기 팩터로 회전율 5% 면 시그널이 안 살아 있을 가능성이 큽니다.
- 거래비용 가정의 함정 — 백테스트에서 Sharpe 2 였는데 15bp 비용 깔면 Sharpe 0.5 로 떨어지는 경우 흔해요. 처음부터 비용 포함 Sharpe 를 기준선으로 두고 보는 게 안전합니다.
7. IC — 팩터 자체의 정보량 평가
정의
t 시점 알파 $A_{i,t}$ 와 다음 기간 수익률 $r_{i, t+1}$ 사이의 횡단면 상관:
\[\text{IC}_t = \text{corr}\bigl(A_{\cdot, t},\, r_{\cdot, t+1}\bigr)\]보통 Spearman(rank) 상관을 써요. 평균 $\overline{\text{IC}}$, 표준편차 $\sigma_{\text{IC}}$ 에서 IR(Information Ratio):
\[\text{IR} = \frac{\overline{\text{IC}}}{\sigma_{\text{IC}}}\]왜 이렇게 계산하나
- 백테스트가 아닌, 팩터 자체의 정보량 을 보는 지표예요. 분위 컷이나 비중 정규화 같은 후처리를 거치지 않은 “순수한 알파의 효과” 를 잡습니다.
- Spearman 을 쓰는 이유 — 알파 분포가 비대칭이거나 outlier 가 있어도, 순위만 살아있으면 IC 가 잡혀요. Pearson 은 outlier 한 종목이 그날 IC 를 통째로 흔들기 쉽습니다.
- IR 이 Sharpe 의 팩터 버전 이에요. IC 평균이 양수여도 시점마다 흔들리면 실거래 결과는 들쭉날쭉. 평균 / 표준편차로 안정성을 같이 봅니다.
Python
def ic(panel: pd.DataFrame, alpha_col: str = "alpha",
fwd_ret_col: str = "ret_fwd1", method: str = "spearman") -> pd.Series:
def _per_day(g):
x, y = g[alpha_col], g[fwd_ret_col]
if x.notna().sum() < 5 or y.notna().sum() < 5:
return np.nan
return x.corr(y, method=method)
return panel.groupby("date").apply(_per_day, include_groups=False)
# 사전에 다음날 수익률 컬럼 만들기
panel["ret_fwd1"] = panel.groupby("ticker")["close"].pct_change().groupby(
panel["ticker"]).shift(-1)
ic_series = ic(panel, "alpha", "ret_fwd1")
print(ic_series.round(3).tail(5))
print(f"IC 평균: {ic_series.mean():.4f}, "
f"std: {ic_series.std():.4f}, "
f"IR: {ic_series.mean()/ic_series.std():.4f}")
date
2025-12-15 -0.1
2025-12-16 -0.1
2025-12-17 0.0
2025-12-18 0.7
2025-12-19 NaN
dtype: float64
IC 평균: -0.0241, std: 0.4764, IR: -0.0505
가짜 데이터라 IC 평균이 거의 0(-0.024) 에 노이즈만 큰(std 0.48) 모양. 실제 알파 후보면 평균 IC 가 0.02~0.05 사이, std 가 0.1 안쪽 정도가 일반적입니다. 마지막 날 NaN 은 다음날 수익률(ret_fwd1)이 미래에 있어서 못 채워진 거 — 마지막 행은 자동으로 평가에서 빠집니다.
해석 / 함정
- 평균 IC 0.02~0.05 면 실무에서 “쓸 만한 팩터” 로 보는 관용 기준. 0.1 이상이 안정적으로 나오면 백테스트가 너무 깔끔한 건 아닌지 의심해봐야 해요(데이터 누수 가능성).
- IC 가 시점에 따라 뒤집히는 팩터(예: 모멘텀의 reversal 구간) 는 단일 IC 평균만으로는 못 잡아요. 시점별 IC 시계열을 항상 같이 봅니다.
8. 자주 빠지는 함정 정리
앞 글의 함정 목록을 확장한 버전이에요.
- 포지션 lag 한 줄 빠뜨림 —
(w * ret).sum()처럼 같은 시점끼리 곱하면 Sharpe 가 비현실적으로 나옵니다. 백테스트 결과가 너무 좋으면 가장 먼저 확인. - 결산일 vs 공시일 — 펀더멘털 팩터를 결산일에 박으면 “보통 45~90일 후 공시되는 정보” 를 미리 본 셈. 늘 공시일 기준 ffill.
- 생존편향(Survivorship) — 상장폐지 종목을 빼고 백테스트하면 부풀려진 성과. 폐지 종목까지 포함된 데이터 소스 사용.
- 거래비용 0 백테스트 — 회전율 큰 팩터는 거래비용 깔면 Sharpe 가 반토막. 처음부터 비용 포함으로 보세요.
- 너무 작은 종목 풀 — 시총 하위/유동성 하위 종목으로 시그널이 몰리면 백테스트는 좋아도 실거래 불가. 사전 유동성 필터 필수.
- 단일 기간 백테스트의 과적합 — 한 기간에서만 좋아 보이는 가중치/임계값은 walk-forward(시간순으로 학습 → 평가를 한 칸씩 미루며 반복) 또는 train/test 분리로 검증해야 의미가 있어요.
- In-sample 가중치 튜닝 — in-sample(IS) 은 학습/튜닝에 쓴 구간, out-of-sample(OOS) 은 학습에 안 쓴 별도 구간 을 말해요. 학습 구간(IS) 에서 IC 최대 가중치를 찾고 같은 구간 Sharpe 를 자랑하는 건 자기 자신을 속이는 일. 늘 OOS 평가.
9. 전체 흐름 한 함수로
위 단계를 한 노트북 안에서 굴리는 최소 골격이에요.
def backtest_multifactor(panel: pd.DataFrame,
factor_signs: dict,
q: float = 0.1,
cost_bps: float = 15) -> dict:
# 1) 표준화
for c in factor_signs:
panel[f"z_{c}"] = cs_zscore(panel, c)
# 2) 결합
for c, sgn in factor_signs.items():
panel[f"a_{c}"] = sgn * panel[f"z_{c}"]
panel["alpha"] = panel[[f"a_{c}" for c in factor_signs]].mean(axis=1, skipna=True)
# 3) 시그널
panel["w"] = decile_signal(panel, "alpha", q=q)
# 4) 포지션 lag + 수익률
close_wide = panel.pivot(index="date", columns="ticker", values="close")
ret_wide = close_wide.pct_change()
w_wide = panel.pivot(index="date", columns="ticker", values="w")
w_lag = w_wide.shift(1)
strat_ret = (w_lag * ret_wide).sum(axis=1)
# 5) 평가
return {
"equity": equity_curve(strat_ret - (cost_bps/1e4)*2*turnover(w_wide)),
"metrics": report(strat_ret, w_wide, cost_bps=cost_bps),
}
5 종목 패널이라 q=0.2 로 호출.
result = backtest_multifactor(
panel,
factor_signs={"per": -1, "roe": +1, "rsi14": -1, "macd_hist": +1},
q=0.2,
cost_bps=15,
)
print("metrics:")
print(result["metrics"].round(4))
print("equity tail:")
print(result["equity"].round(4).tail(3))
# result["equity"].plot() # 그래프로 보고 싶을 때
metrics:
ann_return -0.7548
ann_vol 0.3360
sharpe -4.2605
mdd -0.4366
turnover 1.2438
hit_ratio 0.3250
dtype: float64
equity tail:
date
2025-12-17 0.6147
2025-12-18 0.6058
2025-12-19 0.6287
dtype: float64
metrics 는 6개 스칼라, equity 는 index=date 인 누적가치 시리즈. 가짜 팩터라 결과는 음수지만, 호출 인터페이스는 그대로 실데이터에 박을 수 있는 모양 이에요 — panel 만 진짜 데이터로 갈아끼우고 factor_signs 만 조정하면 끝.
✅ 핵심 흐름은 표준화 → 결합 → 분위 시그널 → lag → 수익률 → 평가 의 6단계. 이 골격을 두고 팩터 후보, 분위 컷, 비용 가정을 바꿔가면서 sensitivity 를 보는 게 실제 리서치의 본 작업이에요.
일단 오늘은 여기까지…..
다음 글에서는 위 백테스트 결과를 두고 팩터 가중치를 out-of-sample 로 학습하는 흐름 (롤링 OLS, ridge, IC 가중) 과 walk-forward 평가 를 정리해볼게요.