퀀트 팩터 직접 계산하기 — PER · RSI · MACD · Sharpe 를 Python + LaTeX 로 풀어보기
Summary
퀀트 팩터는 라이브러리 한 줄(ta.rsi(...))로 뽑을 수 있어요. 다만 그렇게만 쓰다 보면 “왜 분모가 저거고, 왜 14일이고, 왜 표준편차로 나누는지” 가 통째로 비어 있더라고요. 팩터 의미가 비어 있으면 백테스트 결과를 해석할 때도 흔들립니다.
그래서 자주 쓰는 팩터들을 정의(LaTeX) → 왜 그렇게 계산하나 → pandas 구현 → 해석/함정 의 같은 4단 포맷으로 묶었어요.
💡 이 글에서 다루는 것
- 가치/수익성 팩터 — EPS · PER · PBR · ROE
- 가격 팩터 — 단순/로그 수익률 · RSI · MACD · Bollinger Bands
- 위험조정 팩터 — Sharpe Ratio · Beta
- 각 팩터의 수식과 pandas / numpy 구현, “왜 이렇게 정의되는가” 한 줄 해설
본문에 들어가기 전에 환경부터 잡고 갈게요. 각 팩터 섹션의 정의식 바로 아래에 “💡 기호 풀기” 박스를 둬서, 처음 보는 기호는 그 자리에서 읽는 법까지 같이 짚고 넘어가요.
0. 공통 환경
코드 예시는 전부 다음 데이터프레임이 있다고 가정합니다.
import numpy as np
import pandas as pd
# df: 일별 OHLCV 데이터, index 는 DatetimeIndex
# columns: ['open', 'high', 'low', 'close', 'volume']
# market: 동일 인덱스의 시장지수 종가 시리즈 (예: KOSPI)
라이브러리는 pandas, numpy 만 씁니다. ta, talib 같은 wrapper 는 한 번 직접 짜 보고 나면 그 다음에 써도 늦지 않아요.
⚠️ 펀더멘털 팩터(EPS, PER, …)는 손익계산서/재무상태표에서 오는 값이라 데이터 소스에 따라 정의가 살짝 다릅니다. 아래 정의는 국제 회계 기준에서 가장 일반적인 형태 기준이에요.
1. EPS — 주당순이익
정의
\[\text{EPS} = \frac{\text{Net Income} - \text{Preferred Dividends}}{\text{Weighted Average Shares Outstanding}}\]왜 이렇게 계산하나
- 분자에서 우선주 배당을 빼는 이유 — EPS 는 “보통주 한 주에 떨어지는 이익”이라서, 우선주 몫은 미리 떼야 보통주 주주의 몫만 남습니다.
- 분모가 가중평균 발행주식 수 인 이유 — 회기 중간에 유상증자/자사주 매입이 있으면 단순 기말 주식 수로 나누면 왜곡돼요. 기간별로 가중평균을 내야 “1주당” 이라는 단위가 살아요.
Python
def eps(net_income: float, preferred_div: float, wa_shares: float) -> float:
return (net_income - preferred_div) / wa_shares
가상의 한 회사 숫자로 돌려보면:
result = eps(net_income=320_000_000_000, # 순이익 3,200억
preferred_div=15_000_000_000, # 우선주 배당 150억
wa_shares=59_697_825) # 가중평균 발행주식수
print(round(result, 2))
5109.06
“보통주 한 주에 약 5,109원의 이익이 떨어진다” 는 의미예요.
벡터화하려면 가중평균 주식수만 미리 만들어두면 돼요.
def weighted_avg_shares(shares_timeline: pd.Series) -> float:
# shares_timeline: 변경 시점별 발행주식수 (index=date)
days = (shares_timeline.index.to_series().diff().dt.days
.shift(-1).fillna(0))
return (shares_timeline * days).sum() / days.sum()
회기 중간에 자사주 매입으로 주식 수가 줄어든 케이스로 돌리면:
timeline = pd.Series(
[60_000_000, 59_000_000, 58_500_000],
index=pd.to_datetime(["2025-01-01", "2025-07-01", "2026-01-01"]),
)
print(round(weighted_avg_shares(timeline), 0))
59495890.0
기말 발행주식수(58,500,000)가 아니라 약 59.5M — 회기 전반의 60M 와 후반의 59M 사이에서 일수 가중평균이 잡혔어요.
해석
EPS 절대값은 회사 사이즈에 휘둘려요. YoY 성장률(EPS growth) 이나 다음에 나올 PER 처럼 가격과 결합해야 비교 가능한 숫자가 됩니다.
2. PER — 주가수익비율
정의
\[\text{PER} = \frac{P}{\text{EPS}}\]여기서 $P$ 는 주가, EPS 는 위 정의대로의 연간 주당순이익이에요.
왜 이렇게 계산하나
- 분자 / 분모의 단위가 같은 “원” 이라 무차원 — 그래서 종목 간 비교가 가능해집니다. “이 회사 시총 1조원” 같은 절대값으로는 비교가 안 돼요.
- 의미는 “이 회사의 이익을 1년치 그대로 받는 데 몇 년이 걸리나” 입니다. PER 10 이면 10년 치 이익으로 주가를 회수하는 셈.
- EPS 가 음수면 PER 가 음수로 떨어져서 의미가 무너집니다. 이때는
NaN처리하는 게 안전해요.
Python
def per(price: pd.Series, eps_annual: pd.Series) -> pd.Series:
# 두 시리즈는 같은 인덱스(분기/연도)로 정렬돼 있다고 가정
out = price / eps_annual
return out.where(eps_annual > 0, np.nan) # 적자기업은 의미 없음
4 개 회사 한 시점 스냅샷으로 돌려봅니다 (적자 회사 한 곳 포함).
price = pd.Series([72_000, 50_000, 9_500, 240_000], index=["A", "B", "C", "D"])
eps_annual = pd.Series([ 5_108, 3_200, -800, 8_500], index=["A", "B", "C", "D"])
print(per(price, eps_annual).round(2))
A 14.10
B 15.62
C NaN
D 28.24
dtype: float64
C 사는 EPS 음수라 NaN — PER 의 의미가 안 살아납니다.
해석
같은 산업 안에서 PER 가 낮은 종목을 “싸다” 고 보는 게 전통적 가치투자 접근이에요. 다만 산업이 다르면 직접 비교 금지 — 성장주는 구조적으로 PER 가 높습니다. 그래서 보통 산업 중앙값 대비 z-score 로 변환해서 씁니다.
def per_zscore(per_series: pd.Series, industry: pd.Series) -> pd.Series:
g = per_series.groupby(industry)
return (per_series - g.transform("median")) / g.transform("std")
같은 산업끼리 묶어서 상대 비교 — 두 산업 6개 종목으로 돌리면:
per_series = pd.Series([14.1, 15.6, 11.2, 28.2, 35.5, 22.1],
index=["A", "B", "C", "D", "E", "F"])
industry = pd.Series(["은행", "은행", "은행", "IT", "IT", "IT"],
index=["A", "B", "C", "D", "E", "F"])
print(per_zscore(per_series, industry).round(2))
A 0.00
B 0.67
C -1.30
D 0.00
E 1.09
F -0.91
dtype: float64
같은 PER 28.2(D) 도 IT 산업 중앙값과 정확히 같아 0, E 의 35.5 는 IT 평균을 한참 위로 벗어나서 +1.09 가 잡혔어요.
3. PBR — 주가순자산비율
정의
\[\text{PBR} = \frac{P}{\text{BPS}}, \quad \text{BPS} = \frac{\text{Total Equity} - \text{Preferred Equity}}{\text{Common Shares Outstanding}}\]왜 이렇게 계산하나
- PER 가 “이익” 기준이라면 PBR 은 “장부가” 기준이에요. 이익은 분기별로 크게 흔들리지만 자본은 천천히 변해서, 이익 변동성이 큰 산업(은행/철강/조선)에서는 PBR 이 더 안정적인 가치 지표가 됩니다.
- PBR 1 미만은 “장부상 청산가치보다 시장이 더 싸게 평가” 라는 뜻. 다만 그게 진짜 저평가인지, 자산 자체가 부실해서 시장이 디스카운트하는 건지는 재무제표를 따로 봐야 알 수 있어요.
Python
def pbr(price: pd.Series, total_equity: pd.Series,
preferred_equity: pd.Series, common_shares: pd.Series) -> pd.Series:
bps = (total_equity - preferred_equity) / common_shares
return (price / bps).where(bps > 0, np.nan)
3 개 종목 한 시점 스냅샷.
idx = ["A", "B", "C"]
price = pd.Series([72_000, 50_000, 240_000], index=idx)
total_equity = pd.Series([3_500_000_000_000, 1_800_000_000_000, 12_000_000_000_000], index=idx)
preferred_eq = pd.Series([ 100_000_000_000, 0, 500_000_000_000], index=idx)
common_shares= pd.Series([ 59_697_825, 36_000_000, 70_000_000], index=idx)
print(pbr(price, total_equity, preferred_eq, common_shares).round(2))
A 1.26
B 1.00
C 1.46
dtype: float64
B 사는 PBR 1.0 — 주가가 정확히 장부가만큼 평가되는 셈이에요. A 와 C 는 장부가의 1.26 / 1.46 배 프리미엄.
해석
PBR 단독으로 쓰지 않고 다음에 나올 ROE 와 결합해서 보는 게 정석이에요. PBR 이 낮은데 ROE 가 높으면 진짜 저평가일 가능성이 높고, PBR 이 낮고 ROE 도 낮으면 자본을 굴리지 못하는 “값싼 이유” 가 있는 회사인 거죠.
4. ROE — 자기자본이익률
정의
\[\text{ROE} = \frac{\text{Net Income}}{\text{Average Equity}}\]평균자본은 보통 $(E_{\text{begin}} + E_{\text{end}}) / 2$ 로 잡습니다.
왜 이렇게 계산하나
- 회사가 “주주가 맡긴 돈으로 이익을 얼마나 만들어냈나” 를 직접 보는 비율이에요. 자본수익률.
- 평균을 쓰는 이유 — 분자(이익)는 한 해 동안 누적된 양인데 분모를 기말 자본만 쓰면 기간이 안 맞아요. 자본조달이 기중에 일어나면 더 심하게 어긋납니다.
- ROE 는 Du Pont 분해 로 풀어보면 의미가 더 또렷해져요.
같은 ROE 라도 “마진이 높은 ROE” 와 “레버리지가 큰 ROE” 는 위험 프로필이 전혀 달라요.
Python
def roe(net_income: pd.Series, equity_begin: pd.Series, equity_end: pd.Series) -> pd.Series:
avg_eq = (equity_begin + equity_end) / 2
return (net_income / avg_eq).where(avg_eq > 0, np.nan)
def dupont(net_income, sales, assets, equity):
margin = net_income / sales
turnover = sales / assets
leverage = assets / equity
return pd.DataFrame({"margin": margin, "turnover": turnover, "leverage": leverage,
"ROE": margin * turnover * leverage})
같은 ROE 0.2 를 마진형(A) 과 레버리지형(B) 두 경로로 만든 케이스로 돌립니다.
idx = ["A_고마진", "B_고레버리지"]
net_income = pd.Series([200, 200], index=idx)
sales = pd.Series([1_000, 4_000], index=idx)
assets = pd.Series([2_000, 8_000], index=idx)
equity = pd.Series([1_000, 1_000], index=idx)
print(roe(net_income, equity_begin=equity, equity_end=equity).round(3))
print(dupont(net_income, sales, assets, equity).round(3))
A_고마진 0.2
B_고레버리지 0.2
dtype: float64
margin turnover leverage ROE
A_고마진 0.20 0.5 2.0 0.2
B_고레버리지 0.05 0.5 8.0 0.2
ROE 는 똑같이 0.2 인데 A 는 마진 20% 로, B 는 자산 8배 레버리지 로 도달했어요. 같은 숫자라도 위험 프로필이 전혀 다르다는 걸 Du Pont 분해가 그대로 보여줍니다.
해석
ROE 가 자본비용($k_e$) 보다 높아야 진짜 가치를 만드는 회사예요. ROE 15% 가 좋아 보여도 자본비용이 18% 면 가치 파괴 중인 셈.
5. 단순 수익률 vs 로그 수익률
정의
단순 수익률(simple return):
\[r_t = \frac{P_t - P_{t-1}}{P_{t-1}} = \frac{P_t}{P_{t-1}} - 1\]로그 수익률(log return):
\[r_t^{\log} = \ln\!\left(\frac{P_t}{P_{t-1}}\right) = \ln P_t - \ln P_{t-1}\]💡 기호 풀기
- $P_t$ — “피 티”. 첨자(아래에 작게 붙는 글자)는 시점 인덱스. $P_{t-1}$ 은 한 시점 전 가격.
- $\ln$ — “엘 엔”. 자연로그(밑이 $e \approx 2.718$ 인 로그). 곱셈을 덧셈으로 바꿔주는 함수 라서, 수익률처럼 “곱해서 누적되는 값” 을 다룰 때 편해요.
- $\sum_{t=1}^{T}$ — “시그마”. $t = 1$ 부터 $T$ 까지 차례로 다 더하라는 합 기호 예요.
- 분수는 한국어로 분모부터 읽어요. $\frac{P_t}{P_{t-1}}$ → “피 티 마이너스 일 분의, 피 티”.
왜 로그 수익률을 자주 쓰나
핵심 이유는 시간 가산성 입니다. 일별 로그 수익률을 그냥 더하면 기간 수익률이 돼요.
\[\sum_{t=1}^{T} r_t^{\log} = \sum_{t=1}^{T} \bigl(\ln P_t - \ln P_{t-1}\bigr) = \ln\!\left(\frac{P_T}{P_0}\right)\]단순 수익률은 이게 안 돼요. $(1+r_1)(1+r_2)\dots(1+r_T) - 1$ 로 곱해야 합니다. 이게 모델링 단계에서 차이를 만들어요.
- 정규성 가정 이 필요한 모델(GARCH, 옵션 가격) → 로그 수익률이 더 정규에 가까움.
- 수익률 합산/평균 하는 모든 통계량 → 로그 수익률이 자연스러움.
- 다만 포트폴리오 단위 합산(자산별 비중 가중) 에서는 단순 수익률이 정확해요. $r_p = \sum_i w_i r_i$ 가 그대로 성립.
Python
def simple_return(close: pd.Series) -> pd.Series:
return close.pct_change()
def log_return(close: pd.Series) -> pd.Series:
return np.log(close / close.shift(1))
5일치 가짜 종가로 두 수익률을 같이 보면:
close = pd.Series(
[100, 102, 99, 105, 110],
index=pd.to_datetime(["2026-01-02", "2026-01-03", "2026-01-06",
"2026-01-07", "2026-01-08"]),
)
print(pd.DataFrame({"close": close,
"simple": simple_return(close).round(4),
"log": log_return(close).round(4)}))
close simple log
2026-01-02 100 NaN NaN
2026-01-03 102 0.0200 0.0198
2026-01-06 99 -0.0294 -0.0299
2026-01-07 105 0.0606 0.0588
2026-01-08 110 0.0476 0.0465
작은 일변동(±2%)에서는 simple/log 가 거의 일치하지만, 하루 +6% 가 떠오르면 둘 사이 차이가 0.0018 까지 벌어져요.
해석
작은 수익률에서는 $\ln(1+r) \approx r$ 이라 둘이 거의 같지만, 일변동이 큰 날(코로나 쇼크 같은)은 차이가 커요. 변동성/위험 모델링은 로그, 포트폴리오 비중 가중은 단순 — 이거 한 짝만 외워두면 헷갈리지 않아요.
6. RSI — 상대강도지수
정의
기간 $n$(보통 14)에 대해,
\[U_t = \max(P_t - P_{t-1},\, 0), \qquad D_t = \max(P_{t-1} - P_t,\, 0)\]Wilder smoothing 을 적용한 평균:
\[\bar{U}_t = \frac{(n-1)\,\bar{U}_{t-1} + U_t}{n}, \qquad \bar{D}_t = \frac{(n-1)\,\bar{D}_{t-1} + D_t}{n}\]상대강도와 RSI:
\[\text{RS}_t = \frac{\bar{U}_t}{\bar{D}_t}, \qquad \text{RSI}_t = 100 - \frac{100}{1 + \text{RS}_t}\]💡 기호 풀기
- $\max(a, 0)$ — “맥스”. 두 값 중 더 큰 쪽. 여기선 음수를 0 으로 잘라내는 트릭으로 써요. 가격이 내려간 날은 $U_t = 0$ 이 됩니다.
- $\bar{U}_t$ — “유 바”. 문자 위 막대(
bar)는 “평균값” 이라는 표기 관습이에요. $\bar{U}$ 는 $U$ 의 평균.- $\text{RS}_t$ — t 시점의 RS 값. RS 는 “Relative Strength” 약자 그대로.
왜 이렇게 계산하나
- 상승폭/하락폭을 따로 평균 내는 이유 — 단순 수익률 평균은 양수/음수가 상쇄돼서 “추세의 강도” 가 안 잡혀요. 절대값으로 떼서 평균하면 “올라가는 힘 vs 내려가는 힘” 의 비율이 됩니다.
- Wilder smoothing 은 사실상 $\alpha = 1/n$ 인 지수이동평균이에요. 일반 EMA(2/(N+1)) 보다 더 매끄럽고, 한 번 값이 큰 충격에 흔들리는 양이 적습니다.
- 0~100 으로 정규화 하는 이유 — $\text{RS} \in [0, \infty)$ 라 그대로 쓰면 시각화/비교가 어려워서, $100 - 100/(1+\text{RS})$ 변환으로 범위를 잘라요. RS = 1 이면 RSI = 50.
Python
def rsi(close: pd.Series, n: int = 14) -> pd.Series:
delta = close.diff()
up = delta.clip(lower=0)
down = -delta.clip(upper=0)
# Wilder smoothing == EWM with alpha = 1/n, adjust=False
avg_up = up.ewm(alpha=1/n, adjust=False).mean()
avg_dn = down.ewm(alpha=1/n, adjust=False).mean()
rs = avg_up / avg_dn
return 100 - 100 / (1 + rs)
실제 RSI 는 n=14 가 관례지만, 손으로 따라가기 좋게 n=3 짧은 시계열로 한 번 돌립니다.
close = pd.Series([100, 102, 99, 103, 105, 102],
index=pd.date_range("2026-01-02", periods=6, freq="B"))
print(rsi(close, n=3).round(2))
2026-01-02 NaN
2026-01-05 100.00
2026-01-06 57.14
2026-01-07 76.92
2026-01-08 82.86
2026-01-09 52.49
Freq: B, dtype: float64
첫날은 NaN(직전 가격이 없어서), 그 다음은 한 칸 오르기만 한 시점이라 RSI=100. 그 뒤로 하락 → 상승이 섞이면서 50~80 사이에서 움직이는 모양이 보여요.
해석
- RSI > 70 과매수, < 30 과매도 는 관용 기준이지만 추세장에서는 위쪽에 한참 붙어있을 수 있어요. 절대 임계값보다 다이버전스(가격은 신고가인데 RSI 는 직전 고점을 못 넘는 등)가 더 정보량이 큽니다.
- 14일은 Wilder 의 원논문 기본값. 단기 트레이딩은 9~10, 장기는 21~25 도 자주 써요.
7. MACD — 이동평균 수렴/발산
정의
EMA(지수이동평균):
\[\alpha_N = \frac{2}{N+1}, \qquad \text{EMA}_N(P)_t = \alpha_N P_t + (1 - \alpha_N)\,\text{EMA}_N(P)_{t-1}\]MACD 선과 시그널 선, 히스토그램:
\[\text{MACD}_t = \text{EMA}_{12}(P)_t - \text{EMA}_{26}(P)_t\] \[\text{Signal}_t = \text{EMA}_{9}(\text{MACD})_t, \qquad \text{Hist}_t = \text{MACD}_t - \text{Signal}_t\]💡 기호 풀기
- $\alpha$ — “알파”. 그리스 문자. 여기선 EMA 의 가중치 (0~1 사이 비율) 로 써요. $\alpha_N$ 처럼 첨자로 어떤 기간 $N$ 의 가중치인지 구분.
- $\text{EMA}_N(P)_t$ — “이엠에이 N, t 시점”. 가격 $P$ 에 기간 $N$ 으로 EMA 를 씌운 값을 $t$ 시점에서 본 것. 식이 어렵다 싶으면 “최근 값에 더 큰 비중을 두는 평균” 한 줄로 우선 읽고 넘어가도 OK.
- $\text{MACD}_t, \text{Signal}_t, \text{Hist}_t$ — 같은 시점 $t$ 의 세 값. MACD ↔ Signal 의 차이가 Hist 라는 구조만 잡으면 끝.
왜 이렇게 계산하나
- EMA - EMA 의 차이는 단순이동평균 차이와 달리, 가까운 과거에 더 큰 가중치 를 줘서 추세 전환을 더 빨리 잡습니다.
- 단기 EMA(12)가 장기 EMA(26)를 위로 뚫으면 MACD > 0 → 단기 추세가 장기 추세 위로 올라옴 → 매수 신호로 해석.
- 시그널(EMA9)을 한 번 더 씌우는 이유 — MACD 자체가 노이즈에 흔들리니까, MACD 의 추세를 다시 한 번 추적하기 위함이에요. 둘이 교차하는 시점이 실질적인 진입 시그널.
- 히스토그램 은 MACD 와 시그널의 차이라서, 0 을 위/아래로 가르는 시점이 “추세 가속/감속” 의 변곡점.
Python
def macd(close: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> pd.DataFrame:
ema_fast = close.ewm(span=fast, adjust=False).mean()
ema_slow = close.ewm(span=slow, adjust=False).mean()
macd_line = ema_fast - ema_slow
signal_line = macd_line.ewm(span=signal, adjust=False).mean()
hist = macd_line - signal_line
return pd.DataFrame({"macd": macd_line, "signal": signal_line, "hist": hist})
span=N 이 위 정의의 $\alpha = 2/(N+1)$ 과 정확히 일치해요. adjust=False 가 핵심 — 기본값 True 면 초반 구간에서 가중치를 정규화해서 수치가 살짝 다르게 나옵니다.
기본 파라미터(12/26/9)로 보려면 종가가 최소 40일 정도 있어야 의미가 있어요. 시드 잡힌 합성 가격으로 돌리면:
np.random.seed(42)
close = pd.Series(100 * np.exp(np.cumsum(np.random.normal(0.001, 0.015, 40))),
index=pd.date_range("2026-01-02", periods=40, freq="B"))
print(macd(close).tail(5).round(3))
macd signal hist
2026-02-20 -1.656 -1.711 0.055
2026-02-23 -1.605 -1.690 0.084
2026-02-24 -1.760 -1.704 -0.057
2026-02-25 -2.000 -1.763 -0.237
2026-02-26 -2.137 -1.838 -0.299
hist 가 양수 → 음수로 뒤집히는 시점(2026-02-24)이 추세 가속 → 감속의 변곡점이에요. 실제 매매에선 이 시점을 시그널 후보로 봅니다.
해석
MACD 는 추세추종이라 횡보장에서는 거짓 신호가 많아요. RSI(역추세) 와 같이 보는 게 정석이에요. “MACD 골든크로스인데 RSI 도 50 이상 추세전환” 같은 2중 확인 으로 진입.
8. Bollinger Bands — 변동성 밴드
정의
기간 $N$(보통 20), 표준편차 배수 $k$(보통 2):
\[\text{MB}_t = \frac{1}{N}\sum_{i=t-N+1}^{t} P_i, \qquad \sigma_t = \sqrt{\frac{1}{N}\sum_{i=t-N+1}^{t}(P_i - \text{MB}_t)^2}\] \[\text{UB}_t = \text{MB}_t + k\,\sigma_t, \qquad \text{LB}_t = \text{MB}_t - k\,\sigma_t\]💡 기호 풀기
- $\sigma$ — “시그마(소문자)”. 표준편차 예요. 데이터가 평균에서 얼마나 떨어져 있는지를 잰 값. $\sigma$ 가 크면 변동이 심한 것.
- $(P_i - \text{MB}_t)^2$ — 각 가격이 평균으로부터 떨어진 거리를 제곱 한 것. 제곱하는 이유는 +/- 부호가 상쇄되지 않게 양수로 통일하려고.
- $\sum_{i=t-N+1}^{t}$ — “최근 $N$ 일” 의 의미예요. $t$ 가 오늘이면 $t-N+1$ 이 $N$ 일 전 → 끝 인덱스에서 거꾸로 $N$ 칸.
- $\text{MB, UB, LB}$ — Middle Band / Upper Band / Lower Band 약자 그대로.
왜 이렇게 계산하나
- 정규분포 가정 아래에서 가격이 $\text{MB} \pm 2\sigma$ 안에 있을 확률은 약 95.4% 입니다. 그래서 $k=2$ 가 “통계적으로 의미있는 이탈” 의 기준선처럼 쓰여요.
- 분모가 $N$ 이냐 $N-1$ 이냐 차이가 있는데, Bollinger 원안은 모표준편차(N으로 나눔) 입니다.
pandas.rolling.std()는 기본이 표본표준편차($N-1$)라서ddof=0으로 맞춰야 해요. - 밴드 폭(UB - LB) 자체가 변동성 지표예요. 좁아지면(squeeze) 변동성 압축 → 곧 큰 움직임이 나올 가능성이 높다는 식의 해석이 가능합니다.
Python
def bollinger(close: pd.Series, n: int = 20, k: float = 2.0) -> pd.DataFrame:
mb = close.rolling(n).mean()
sd = close.rolling(n).std(ddof=0) # 모표준편차
ub = mb + k * sd
lb = mb - k * sd
width = (ub - lb) / mb # 변동성 압축 모니터링용
return pd.DataFrame({"mb": mb, "ub": ub, "lb": lb, "width": width})
위 MACD 예시의 close 를 그대로 받아 돌리면:
print(bollinger(close, n=20, k=2).tail(3).round(3))
mb ub lb width
2026-02-24 96.301 99.766 92.836 0.072
2026-02-25 95.903 99.879 91.926 0.083
2026-02-26 95.621 100.044 91.199 0.093
width 가 0.072 → 0.093 으로 늘어났네요 — 같은 구간에서 변동성이 점차 확장되고 있다는 신호. width 가 거꾸로 압축되다가 급격히 확장 시작하는 시점이 통상 “워킹 더 밴드” 의 진입 후보로 잡혀요.
해석
- 밴드 터치 = 매매 신호 는 단순한 해석이고, 실제로는 추세장에서는 상단 밴드를 따라 올라가는 “워킹 더 밴드” 가 잦아요.
- 더 안전한 해석은 밴드 폭 + MACD 조합. 밴드가 압축돼 있다가 확장 시작하는 시점 + MACD 가 0선 돌파면 추세 전환의 1차 후보로 봅니다.
9. Sharpe Ratio — 위험조정수익
정의
\[\text{Sharpe} = \frac{\mathbb{E}[R_p] - R_f}{\sigma_p}\]일별 데이터로 추정하고 연환산할 때(주식 거래일 252일 기준):
\[\text{Sharpe}_{\text{ann}} = \frac{\bar{r} - r_f^{\text{daily}}}{s} \cdot \sqrt{252}\]여기서 $\bar{r}, s$ 는 일별 (로그) 수익률의 평균과 표준편차예요.
💡 기호 풀기
- $\mathbb{E}[\cdot]$ — “기댓값”. 확률적인 평균이에요. 실제 데이터로 계산할 땐 표본평균 $\bar{r}$ 로 대체.
- $R_p, R_f$ — 포트폴리오 수익률(p = portfolio) 과 무위험수익률(f = risk-free). 첨자는 “종류를 가리키는 라벨” 역할이에요. 시점 인덱스가 아니라는 점만 주의.
- $\sqrt{252}$ — “루트 252”. 1 년 거래일이 약 252 일. 분산은 시간에 비례하고, 표준편차는 그 제곱근에 비례해서 $\sqrt{252}$ 가 등장합니다.
왜 이렇게 계산하나
- 분모를 표준편차로 나누는 의미 — 같은 평균수익률이라도 변동성이 크면 실제 손에 들어오는 결과는 더 불확실해요. 그 불확실성을 페널티로 깔자는 게 핵심 아이디어.
- $\sqrt{252}$ 로 곱하는 이유 — 분산은 시간에 비례(수익률이 시점간 독립이라고 두면), 표준편차는 $\sqrt{\text{시간}}$ 에 비례. 평균은 시간에 비례하니까 분자는 $\times 252$, 분모는 $\times \sqrt{252}$ → 전체 $\sqrt{252}$ 배가 됩니다.
- 무위험수익률($R_f$)을 빼는 이유 — “내가 위험을 감수해서 얻은 추가 수익” 만 평가하려는 거예요. 예금 이자보다 못한 펀드는 Sharpe 가 음수가 돼야 정상.
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)
1년치(252일) 가짜 수익률 — 일간 평균 0.06%, 변동성 1.2% 가정 — 으로 한 번 돌려보면:
np.random.seed(1)
returns = pd.Series(np.random.normal(0.0006, 0.012, 252),
index=pd.date_range("2025-01-02", periods=252, freq="B"))
print(round(sharpe(returns), 3))
1.895
연환산 Sharpe 1.89 — 일간 기준의 작은 평균/표준편차에 $\sqrt{252}$ 가 곱해지면서 연 기준으로 확대돼요. 이론적으로 평균/표준편차 비율 0.05 * $\sqrt{252}$ ≈ 0.79 와 비슷하게 나와야 하는데, 임의 표본에서 평균이 가정값보다 살짝 올라가서 더 큰 값이 잡혔어요.
해석
- Sharpe > 1 정도면 “괜찮은” 전략, > 2 면 “꽤 좋은” 전략. 다만 단점이 큼 — 표준편차는 위/아래 변동성을 똑같이 페널티로 잡기 때문에, 수익률 분포가 비대칭(꼬리위험 큰 경우)이면 Sharpe 가 과대평가될 수 있어요.
- 그래서 실무에서는 Sortino(하방 편차만 쓰는 Sharpe 변형)와 같이 봅니다.
10. Beta — 시장 민감도
정의
종목 $i$ 의 시장 베타:
\[\beta_i = \frac{\text{Cov}(R_i, R_m)}{\text{Var}(R_m)}\]OLS 회귀 형태로 보면 $R_i = \alpha_i + \beta_i R_m + \varepsilon_i$ 의 회귀계수 그 자체예요.
💡 기호 풀기
- $\text{Cov}(X, Y)$ — “공분산”. $X$ 가 평균보다 클 때 $Y$ 도 평균보다 큰 경향이 있는지(같이 움직이는지)를 잰 값. 양수면 같은 방향, 음수면 반대 방향.
- $\text{Var}(X)$ — “분산”. $X$ 가 자기 평균에서 얼마나 흩어져 있는지. 표준편차 $\sigma$ 의 제곱($\sigma^2$) 이에요.
- $\alpha_i, \beta_i, \varepsilon_i$ — 회귀식의 절편($\alpha$, 알파), 기울기($\beta$, 베타), 오차($\varepsilon$, 엡실론). 첨자 $i$ 는 종목 인덱스 예요.
왜 이렇게 계산하나
- 공분산 / 시장분산 의 의미 — 시장이 1% 움직일 때 이 종목이 평균적으로 몇 % 움직이는지의 비율이에요. 그래서 $\beta=1$ 은 “시장과 같이”, $\beta=1.5$ 는 “시장보다 1.5배 출렁”, $\beta < 0$ 은 “시장과 반대로”.
- CAPM 의 기대수익률 모형 $\mathbb{E}[R_i] = R_f + \beta_i(\mathbb{E}[R_m] - R_f)$ 에서 베타가 그대로 위험 프리미엄의 계수로 들어가요. 그래서 베타 = 시장위험의 노출도 라고 해석.
- 분산 $\text{Var}(R_m)$ 으로 나누는 이유는 회귀의 정의 그 자체. 두 변수 사이의 선형 의존성을 시장 분산에 대해 정규화한 양입니다.
Python
def beta(stock_ret: pd.Series, market_ret: pd.Series) -> float:
df = pd.concat([stock_ret, market_ret], axis=1).dropna()
cov = df.cov().iloc[0, 1]
var_m = df.iloc[:, 1].var(ddof=1)
return cov / var_m
def rolling_beta(stock_ret: pd.Series, market_ret: pd.Series, window: int = 60) -> pd.Series:
cov = stock_ret.rolling(window).cov(market_ret)
var_m = market_ret.rolling(window).var(ddof=1)
return cov / var_m
가짜 시장 + “시장보다 1.3 배 출렁이는” 종목 시리즈로 250일치 만들어 돌려봅니다.
np.random.seed(7)
market_ret = pd.Series(np.random.normal(0.0004, 0.011, 250),
index=pd.date_range("2025-01-02", periods=250, freq="B"),
name="market")
stock_ret = pd.Series(1.3 * market_ret.values + np.random.normal(0, 0.008, 250),
index=market_ret.index, name="A")
print("beta =", round(beta(stock_ret, market_ret), 4))
print(rolling_beta(stock_ret, market_ret, window=60).round(3).tail(3))
beta = 1.2727
2025-12-15 1.282
2025-12-16 1.285
2025-12-17 1.284
Freq: B, dtype: float64
설정해둔 진짜 베타 1.3 에 가까운 1.27 이 잡혀요. 60일 롤링 베타도 1.28 근방에서 거의 일정 — 시뮬레이션이라 베타가 시간에 따라 안 변하는 케이스.
회귀로 직접 풀어도 동일해요.
import statsmodels.api as sm
def beta_ols(stock_ret: pd.Series, market_ret: pd.Series):
df = pd.concat([stock_ret, market_ret], axis=1).dropna()
X = sm.add_constant(df.iloc[:, 1])
return sm.OLS(df.iloc[:, 0], X).fit() # params.iloc[1] 이 beta
res = beta_ols(stock_ret, market_ret)
print("alpha =", round(res.params.iloc[0], 6),
" beta =", round(res.params.iloc[1], 4))
alpha = -0.000785 beta = 1.2727
beta 함수 결과(1.2727)와 OLS beta(1.2727)가 소수점까지 일치 — 분산/공분산 비율이 곧 단순회귀 기울기라는 게 직접 확인됩니다.
해석
- 베타는 시간에 따라 변합니다. 한 시점에서 잰 베타를 5년 뒤에 그대로 쓰면 위험합니다. 보통 60~252일 롤링 베타 를 같이 봐요.
- 저베타 ≠ 안전 — 베타는 시장 방향성에만 반응하는 민감도라서, 베타가 낮아도 종목 고유 위험(idiosyncratic risk)이 클 수 있어요. 분산투자로 줄어드는 건 후자입니다.
11. 한 번에 묶어 쓰는 패턴
위 함수들을 같은 df 위에서 한 번에 만들면 보통 이렇게 됩니다.
def build_factors(df: pd.DataFrame, market: pd.Series) -> pd.DataFrame:
out = pd.DataFrame(index=df.index)
out["ret"] = log_return(df["close"])
out["rsi14"] = rsi(df["close"], n=14)
macd_df = macd(df["close"])
out[["macd", "macd_sig", "macd_hist"]] = macd_df[["macd", "signal", "hist"]]
bb = bollinger(df["close"], n=20, k=2)
out[["bb_mb", "bb_ub", "bb_lb", "bb_w"]] = bb
out["beta60"] = rolling_beta(out["ret"], log_return(market), window=60)
return out
120일치 가짜 OHLCV + 시장지수로 한 번 돌려봅니다.
np.random.seed(0)
n_days = 120
idx = pd.date_range("2025-07-01", periods=n_days, freq="B")
close_path = 100 * np.exp(np.cumsum(np.random.normal(0.0006, 0.014, n_days)))
df = pd.DataFrame({
"open": close_path * (1 + np.random.normal(0, 0.002, n_days)),
"high": close_path * (1 + np.abs(np.random.normal(0, 0.005, n_days))),
"low": close_path * (1 - np.abs(np.random.normal(0, 0.005, n_days))),
"close": close_path,
"volume": np.random.randint(10_000, 100_000, n_days),
}, index=idx)
market = pd.Series(100 * np.exp(np.cumsum(np.random.normal(0.0004, 0.010, n_days))),
index=idx)
print(build_factors(df, market).tail(3).round(3))
ret rsi14 macd macd_sig macd_hist bb_mb bb_ub bb_lb bb_w beta60
2025-12-11 -0.002 76.395 5.432 4.548 0.885 121.322 133.373 109.270 0.199 0.420
2025-12-12 0.009 77.664 5.570 4.752 0.818 122.209 134.792 109.625 0.206 0.385
2025-12-15 0.014 79.432 5.758 4.953 0.805 123.150 136.373 109.927 0.215 0.360
✅ 같은 인덱스로 정렬된 OHLCV + 시장지수만 있으면, 위 한 함수로 가격 기반 팩터 + 베타 를 한 번에 뽑을 수 있어요. 펀더멘털 팩터(EPS/PER/PBR/ROE)는 분기 데이터라서 별도의 일자 매핑(예:
reindex().ffill())을 거쳐서 합쳐주면 OK.
12. 자주 빠지는 함정
- Look-ahead bias — 분기 펀더멘털을 사용할 때 “공시일” 이 아니라 “결산일” 기준으로 붙이면 미래 정보를 미리 본 백테스트가 돼요. 늘 공시 가능 시점 이후 의 인덱스로 ffill.
- Survivorship bias — 상장폐지된 종목을 데이터셋에서 빼고 백테스트하면 과거 성과가 부풀려져요. 폐지 종목까지 포함된 데이터 소스를 쓰세요.
- 결측치 처리 — RSI/MACD/Bollinger 는 모두 워밍업 구간에
NaN이 생깁니다. 백테스트 진입 신호를 만들 때는 워밍업 끝난 이후 인덱스로만 트레이딩. - 표본/모표준편차 차이 — Bollinger 는 모표준편차(
ddof=0), Sharpe 는 표본표준편차(ddof=1) 가 관례. 작아 보여도 값이 살짝 달라져서 시각화/리서치 비교가 안 맞을 수 있어요.
일단 오늘은 여기까지…..
다음 글에서는 위 팩터들을 같은 데이터프레임에 묶어서 간단한 멀티팩터 알파를 만들고 백테스트 하는 흐름을 정리해볼게요.