2026. 5. 2. 23:02ㆍ카테고리 없음
Passive
미국 지수 투자자를 위한 금융 데이터 분석 사이트
dinsightlab.com
GaussianHMM으로 S&P 500 시장 레짐을 분류한 방법
왜 시장 레짐 분류가 필요했는가
Passive를 기획할 때 가장 먼저 고민한 것은 "사용자에게 어떤 판단 근거를 줄 수 있는가"였다.
단순히 주가 차트나 ETF 수익률을 보여주는 서비스는 이미 넘쳐난다. 내가 만들고 싶었던 건 "지금 시장이 어떤 국면인지"를 데이터로 명확하게 알려주는 기능이었다. 같은 ETF라도 상승기에 사는 것과 하락기에 사는 것은 완전히 다른 결과를 만들기 때문이다.
문제는 시장 국면이라는 게 직접 관측할 수 없다는 점이다. 우리가 볼 수 있는 건 주가 수익률, VIX, 스프레드 같은 관측 데이터뿐이고, 그 이면의 구조적 상태는 추론해야 한다. 이 문제를 풀기 위해 HMM(Hidden Markov Model)을 선택했다.
HMM을 선택한 이유
시장 레짐 분류에 사용할 수 있는 방법은 여러 가지다.
방법 장점 단점
| 이동평균 기반 규칙 | 구현 단순 | 후행 지표, 노이즈 민감 |
| K-Means 클러스터링 | 직관적 | 시계열 구조 무시, 전이 확률 없음 |
| Regime-Switching GARCH | 변동성 모델링 강점 | 구현 복잡, 수렴 불안정 |
| GaussianHMM | 시계열 구조 반영, 전이 확률 모델링, 해석 가능 | 상태 수 사전 설정 필요 |
| 딥러닝 (LSTM) | 복잡한 패턴 학습 | 블랙박스, 과적합 위험, 해석 불가 |
GaussianHMM을 선택한 핵심 이유는 두 가지다.
첫째, 전이 확률 행렬을 얻을 수 있다. 단순히 "지금 강세 국면"이 아니라 "강세 국면에서 전환 국면으로 이동할 확률이 8%"라는 정보를 줄 수 있다. 이게 투자 판단에 훨씬 유용하다.
둘째, 각 상태의 분포(평균, 분산)를 해석할 수 있다. 모델이 어떤 기준으로 상태를 나눴는지 수치로 확인할 수 있어서 블랙박스 문제가 없다.
1단계 — 변수 선택: 7개 변수를 고른 과정
처음에는 변수를 많이 넣을수록 좋다고 생각했다. 틀렸다.
초기 시도 — 20개 변수
처음에는 VIX, 거래량, 이동평균, RSI, MACD, 섹터 ETF 수익률 등 20개 가까운 변수를 넣었다. 결과는 엉망이었다. 상태 분류가 불안정했고, 같은 데이터를 다시 돌릴 때마다 결과가 달랐다.
문제는 두 가지였다.
- 다중공선성: 서로 높은 상관관계를 가진 변수들이 모델을 불안정하게 만들었다
- 차원의 저주: 변수가 많아질수록 GaussianHMM의 공분산 추정이 불안정해진다
변수 선별 기준 3가지
변수를 줄이면서 세운 기준:
기준 1 — 경제적 의미가 명확해야 한다 VIX 기간 구조를 넣은 이유는 단순히 상관관계가 높아서가 아니라, 단기·장기 변동성의 비율이 시장 참여자들의 공포 구조를 반영하기 때문이다. 경제적 해석이 가능한 변수만 남겼다.
기준 2 — 서로 다른 정보를 담아야 한다 VIX와 풋/콜 비율은 둘 다 센티먼트를 반영하지만 시장 참여자 집단이 다르다. 그러나 단기 VIX와 장기 VIX 간 상관관계는 너무 높아서 기간 구조(비율)로 변환했다.
기준 3 — 데이터 가용성이 안정적이어야 한다 FRED API로 안정적으로 수집할 수 있는 지표여야 한다. 일부 대안 데이터(옵션 플로우, 다크풀 거래량)는 데이터 소스가 불안정해서 제외했다.
최종 선택 7개 변수
features = [
'fundamental_gap', # 펀더멘탈-주가 괴리
'erp_zscore', # 주식 리스크 프리미엄 Z-Score
'residual_correlation', # 주가-이익 추정치 잔차 상관계수
'dispersion', # 섹터 간 수익률 분산도
'amihud_illiquidity', # Amihud 비유동성 지표
'vix_term_structure', # VIX 기간 구조 (단기/장기 비율)
'hy_spread' # 하이일드 채권 스프레드
]
각 변수가 담고 있는 정보:
변수 측정 대상 데이터 소스
| fundamental_gap | 주가 vs 이익 추정치 괴리 | FRED + 시장 데이터 |
| erp_zscore | 주식 리스크 프리미엄 수준 | FRED |
| residual_correlation | 펀더멘탈 이탈 정도 | 계산값 |
| dispersion | 섹터 간 쏠림 현상 | 시장 데이터 |
| amihud_illiquidity | 시장 깊이 (유동성) | 시장 데이터 |
| vix_term_structure | 공포의 기간 구조 | CBOE |
| hy_spread | 신용 리스크 수준 | FRED |
2단계 — 상태 수(K) 결정: 4가지가 최적인 이유
HMM에서 가장 중요한 하이퍼파라미터는 상태 수(K)다. 정답이 없고 데이터와 목적에 맞게 결정해야 한다.
K=2, 3, 4, 5 비교 실험
from hmmlearn.hmm import GaussianHMM
import numpy as np
results = {}
for n_states in [2, 3, 4, 5]:
model = GaussianHMM(
n_components=n_states,
covariance_type='full',
n_iter=1000,
random_state=42
)
model.fit(X_scaled)
results[n_states] = {
'aic': -2 * model.score(X_scaled) + 2 * model._get_n_fit_scalars_per_param()['t'],
'bic': -2 * model.score(X_scaled) + np.log(len(X_scaled)) * model._get_n_fit_scalars_per_param()['t'],
'log_likelihood': model.score(X_scaled)
}
K AIC BIC 해석 가능성 안정성
| 2 | 낮음 | 낮음 | 너무 단순 (상승/하락만) | 높음 |
| 3 | 중간 | 중간 | 괜찮음 (상승/횡보/하락) | 높음 |
| 4 | 최적 | 최적 | 강세/약세/횡보/전환 구분 | 높음 |
| 5 | 높음 | 높음 | 상태 간 경계 불명확 | 낮음 |
K=4가 최적이었던 결정적 이유는 BIC 기준 수치 외에도 실제 시장 해석이 자연스럽다는 점이었다.
- 상태 0 (강세): μ > 0, σ 낮음 → 저변동성 상승
- 상태 1 (약세): μ < 0, σ 높음 → 고변동성 하락
- 상태 2 (횡보): μ ≈ 0, σ 중간 → 방향 없는 등락
- 상태 3 (전환): μ 불안정, σ 매우 높음 → 추세 전환 구간
K=5로 늘렸을 때 상태 2와 4가 거의 동일한 분포를 가지는 문제가 발생했다. 같은 정보를 두 개 상태가 나눠 갖는 불필요한 분할이었다.
3단계 — 과적합 방지: 가장 고생한 부분
GaussianHMM의 과적합은 학습 데이터에서는 깔끔하게 분류되지만 새로운 데이터에서 상태가 불안정하게 튀는 현상으로 나타난다.
문제 1 — 초기값 민감성
GaussianHMM은 EM 알고리즘으로 학습하는데, 초기값에 따라 로컬 최적값에 빠지는 문제가 있다. 같은 데이터를 돌려도 랜덤 시드에 따라 결과가 달랐다.
해결책: 멀티스타트 + 최적 모델 선택
best_model = None
best_score = -np.inf
for seed in range(50): # 50회 랜덤 초기화
model = GaussianHMM(
n_components=4,
covariance_type='full',
n_iter=1000,
random_state=seed
)
try:
model.fit(X_scaled)
score = model.score(X_scaled)
if score > best_score:
best_score = score
best_model = model
except Exception:
continue
50회 반복 중 로그 우도가 가장 높은 모델을 선택했다. 이 방식으로 초기값 민감성 문제를 크게 줄였다.
문제 2 — 공분산 행렬 특이점(Singularity)
변수 간 상관관계가 높거나 특정 구간 데이터가 부족하면 공분산 행렬이 특이점(singular)이 되어 모델이 발산한다.
해결책: 정규화 공분산 + 스케일링
from sklearn.preprocessing import RobustScaler
# RobustScaler: 이상치에 강한 스케일링
scaler = RobustScaler()
X_scaled = scaler.fit_transform(X)
# covariance_type='diag'로 변경 시도
# full → tied → diag 순으로 제약을 강하게
model = GaussianHMM(
n_components=4,
covariance_type='diag', # 대각 공분산으로 제약
n_iter=1000,
random_state=42
)
최종적으로는 covariance_type='full'을 유지하되, 충분한 학습 데이터(10년 이상)와 RobustScaler를 조합해 안정성을 확보했다.
문제 3 — 상태 레이블 불일치 (Label Switching)
HMM은 상태 번호가 고정되어 있지 않다. 어떤 날은 상태 0이 강세 국면이고, 다음 날 모델을 다시 돌리면 상태 2가 강세 국면이 될 수 있다.
해결책: 평균 수익률 기준 자동 정렬
def align_states(model, returns):
"""각 상태의 평균 수익률로 상태 정렬"""
state_means = []
hidden_states = model.predict(X_scaled)
for state in range(model.n_components):
mask = hidden_states == state
mean_return = returns[mask].mean()
state_means.append((state, mean_return))
# 평균 수익률 기준 정렬
# 가장 높은 수익률 = 강세, 가장 낮은 = 약세
sorted_states = sorted(state_means, key=lambda x: x[1], reverse=True)
state_mapping = {
sorted_states[0][0]: 'bull', # 강세
sorted_states[3][0]: 'bear', # 약세
# 횡보/전환은 변동성 기준으로 추가 분류
}
return state_mapping
평균 수익률과 변동성을 함께 사용해 4개 상태를 강세/약세/횡보/전환으로 자동 정렬하는 로직을 구현했다.
4단계 — 검증: 내가 만든 기준
모델을 만든 후 "이게 진짜 잘 작동하는가"를 확인하는 게 가장 어려웠다. 시장 레짐에는 정답 레이블이 없기 때문이다.
검증 기준 1 — 역사적 사건과의 일치도
모델이 분류한 약세/전환 국면이 실제 주요 시장 하락 사건과 일치하는지 확인했다.
사건 실제 기간 모델 감지 시점 선행 여부
| 코로나 쇼크 | 2020.02~03 | 2020.02 중순 | 약 1~2주 선행 |
| 2022년 약세장 | 2022.01~10 | 2021.12 말 | 약 2~3주 선행 |
| 2018년 4분기 조정 | 2018.10~12 | 2018.10 초 | 동시 감지 |
완벽하지는 않지만 주요 하락 사건에서 유의미한 선행 또는 동시 감지를 확인했다.
검증 기준 2 — 레짐별 수익률 분포 유의성
각 레짐별 S&P 500 일간 수익률 분포가 통계적으로 유의미하게 다른지 검정했다.
from scipy import stats
bull_returns = returns[states == 'bull']
bear_returns = returns[states == 'bear']
# Mann-Whitney U 검정 (정규성 가정 없음)
stat, p_value = stats.mannwhitneyu(bull_returns, bear_returns)
print(f"p-value: {p_value:.6f}") # 결과: p < 0.001
강세/약세 국면 수익률 분포 차이의 p-value가 0.001 미만으로, 모델이 통계적으로 유의미한 구분을 하고 있음을 확인했다.
검증 기준 3 — Walk-Forward 안정성 테스트
데이터를 시간 순서대로 잘라서 각 구간에서 모델을 다시 학습하고, 레짐 분류 결과가 일관성 있게 유지되는지 확인했다.
# 2년치 학습 → 다음 6개월 예측 → 슬라이딩
for i in range(0, len(data) - train_size, test_size):
train = data[i:i+train_size]
test = data[i+train_size:i+train_size+test_size]
model.fit(scaler.fit_transform(train))
test_states = model.predict(scaler.transform(test))
# 각 구간별 레짐 일관성 평가
배포 후 실제로 발견한 문제들
모델을 Railway + FastAPI로 배포하고 실제 사용자 데이터가 쌓이면서 예상치 못한 문제들이 나왔다.
문제 1 — 데이터 갱신 시 레짐이 급격히 바뀌는 현상
매일 새 데이터가 추가될 때마다 전체 모델을 재학습하면, 어제 "강세 국면"이었던 게 오늘 갑자기 "횡보 국면"으로 바뀌는 일이 발생했다. 사용자 입장에서는 신뢰성 문제였다.
해결책: 주간 배치 재학습 + 일간 상태 예측 분리
# 모델 재학습: 매주 일요일 GitHub Actions
# 상태 예측: 매일 학습된 모델로 predict만 실행
모델 자체는 주 1회 재학습하고, 일간 업데이트는 기존 모델의 predict만 사용하도록 분리해 안정성을 확보했다.
문제 2 — 전환 국면이 너무 짧게 감지되는 현상
전환 국면이 하루 이틀씩 산발적으로 나타나 사용자가 "이게 의미 있는 신호인가"라고 의심하는 피드백이 나왔다.
해결책: 최소 지속 기간 필터 적용
def apply_persistence_filter(states, min_duration=3):
"""3거래일 미만 지속되는 레짐 변화는 무시"""
filtered = states.copy()
# 슬라이딩 윈도우로 단기 노이즈 제거
...
return filtered
3거래일 미만 지속되는 레짐 변화는 이전 상태로 유지하는 필터를 추가했다.
이 프로젝트에서 배운 것
데이터 분석가로서 이 모델을 만들면서 가장 크게 배운 점은 "모델 선택보다 문제 정의와 검증 기준 설계가 더 중요하다"는 것이다.
처음에 변수를 20개 넣고 실패했을 때, 문제는 알고리즘이 아니었다. "어떤 정보를 넣어야 시장 국면을 잘 구분할 수 있는가"라는 질문에 제대로 답하지 못한 것이 문제였다.
정답 레이블이 없는 비지도 학습 문제에서는 특히 검증 기준을 스스로 설계해야 한다. 역사적 사건과의 일치도, 통계적 유의성 검정, Walk-Forward 안정성 테스트라는 세 가지 기준을 만든 것이 이 프로젝트에서 가장 중요한 분석 작업이었다.
모델을 배포하고 실제 사용자 피드백을 받으면서 "학습 환경과 운영 환경의 차이"를 체감했다. 논문이나 노트북에서 잘 되던 모델이 실시간 데이터 파이프라인 위에서는 다르게 동작한다. 이 경험이 데이터 분석가로서 가장 값진 배움이었다.
#GaussianHMM #시장레짐분류 #머신러닝 #데이터분석 #파이썬 #HMM #비지도학습 #시계열분석 #퀀트 #데이터사이언스 #Passive개발일지 #미국주식AI #주식머신러닝 #hmmlearn #데이터분석가 #포트폴리오 #개발일지 #FastAPI #Railway #사이드프로젝트