15 분 소요

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_retindex=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 정의를 그대로 가져와요.

\[\text{Sharpe}_{\text{ann}} = \frac{\bar{r} - r_f^{\text{daily}}}{s_r} \cdot \sqrt{252}\]

회전율(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개 스칼라, equityindex=date 인 누적가치 시리즈. 가짜 팩터라 결과는 음수지만, 호출 인터페이스는 그대로 실데이터에 박을 수 있는 모양 이에요 — panel 만 진짜 데이터로 갈아끼우고 factor_signs 만 조정하면 끝.

✅ 핵심 흐름은 표준화 → 결합 → 분위 시그널 → lag → 수익률 → 평가 의 6단계. 이 골격을 두고 팩터 후보, 분위 컷, 비용 가정을 바꿔가면서 sensitivity 를 보는 게 실제 리서치의 본 작업이에요.



일단 오늘은 여기까지…..
다음 글에서는 위 백테스트 결과를 두고 팩터 가중치를 out-of-sample 로 학습하는 흐름 (롤링 OLS, ridge, IC 가중) 과 walk-forward 평가 를 정리해볼게요.