GaussianHMM으로 S&P 500 시장 레짐을 분류한 방법

2026. 5. 2. 23:02카테고리 없음

https://dinsightlab.com

 

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개 가까운 변수를 넣었다. 결과는 엉망이었다. 상태 분류가 불안정했고, 같은 데이터를 다시 돌릴 때마다 결과가 달랐다.

문제는 두 가지였다.

  1. 다중공선성: 서로 높은 상관관계를 가진 변수들이 모델을 불안정하게 만들었다
  2. 차원의 저주: 변수가 많아질수록 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 #사이드프로젝트