포스트

2026-04-15 강의 정리

추천 시스템 - 평가용 데이터셋 합성

2026-04-15 강의 정리

목차

  1. 이동평균 시각화 (SMA, EMA)
  2. 트렌드 분해 (seasonal_decompose)
  3. 다중 ETF 누적 수익률 비교
  4. 상관관계 분석
  5. 성과 지표 계산 (수익률/변동성/MDD/샤프)
  6. LLM 인사이트 생성
  7. 시계열 리포트 생성 함수
  8. 데이터 병합 (pd.merge, pd.concat)
  9. Long Format 분석 (groupby, pivot_table)
  10. 자주 나오는 실수 / 주의사항
  11. [보충] statsmodels seasonal_decompose

1. 이동평균 시각화 (SMA, EMA)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from statsmodels.tsa.seasonal import seasonal_decompose

close = ffill_df['Close']

sma_20 = close.rolling(20).mean()          # 단순 이동평균
ema_20 = close.ewm(span=20).mean()         # 지수 이동평균

# 시각화
fig, axes = plt.subplots(2, 1, figsize=(12, 7))
ax = axes[0]
ax.plot(close.index, close, alpha=0.4, linewidth=0.5, label='original')
ax.plot(sma_20.index, sma_20, linewidth=1.5, label='SMA_20', color='red')
ax.plot(ema_20.index, ema_20, linewidth=1.5, label='EMA_20', color='green')
ax.set_title('Moving Average')

SMA vs EMA 비교:

구분특징반응 속도
SMA (단순 MA)최근 N개 값을 동일 가중치느림 (노이즈 제거 유리)
EMA (지수 MA)최근 값에 더 큰 가중치빠름 (추세 전환 포착 유리)

2. 트렌드 분해 (seasonal_decompose)

시계열 = 트렌드 + 계절성 + 잔차

1
2
3
4
5
6
7
8
# 주별로 리샘플링 후 분해 (일별 데이터가 많아야 seasonality 추정 가능)
weekly = close.resample('W').last().dropna()
decomp = seasonal_decompose(weekly, model='additive', period=13)

# 구성 요소 접근
decomp.trend      # 장기 추세
decomp.seasonal   # 주기적 패턴
decomp.resid      # 잔차

additive vs multiplicative 모델:

1
2
3
4
additive:       y = trend + seasonal + residual
                → 계절성 크기가 시간에 따라 일정할 때
multiplicative: y = trend × seasonal × residual
                → 계절성 크기가 트렌드에 비례해 커질 때 (주가에 더 적합)

3. 다중 ETF 누적 수익률 비교

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
tickers = {
    '069500': 'KODEX 200 (국내주식)',
    '360750': 'TIGER S&P500 (미국주식)',
    '152380': 'KODEX 국고채10년 (채권)',
    '132030': 'KODEX 골드선물 (원자재)',
}

# 각 ETF의 종가만 합쳐서 DataFrame 구성
multi_close = pd.DataFrame()
for ticker, name in tickers.items():
    df = fdr.DataReader(ticker, start=...)
    multi_close[name] = df['Close']

multi_close = multi_close.dropna()

# 일별 수익률
returns = multi_close.pct_change().dropna()

# 누적 수익률 (1.0 = 시작점)
cum_returns = (1 + returns).cumprod()

실행 결과 예시:

1
2
                 KODEX 200  TIGER S&P500  KODEX 국고채10년  KODEX 골드선물
2026-04-15        2.834486      1.344877        0.952980      1.426077

→ KODEX 200은 1년 만에 약 183% 수익, 국고채는 -5% 손실


4. 상관관계 분석

Pearson 상관계수

1
corr_matrix = returns.corr()

실행 결과:

1
2
3
4
5
                   KODEX 200  TIGER S&P500  KODEX 국고채10년  KODEX 골드선물
KODEX 200           1.000000      0.293541       0.389003      0.383096
TIGER S&P500        0.293541      1.000000       0.031366     -0.068603
KODEX 국고채10년       0.389003      0.031366       1.000000      0.165718
KODEX 골드선물         0.383096     -0.068603       0.165718      1.000000

TIGER S&P500과 KODEX 골드선물의 상관계수 ≈ -0.07 → 서로 반대로 움직이는 경향 → 분산투자 효과 기대

롤링 상관관계 (60일 창)

1
2
cols = returns.columns.tolist()
rolling_corr = returns[cols[0]].rolling(60).corr(returns[cols[1]])

5. 성과 지표 계산

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def calculate_metrics(returns_series, risk_free=0.035):
    # 연환산 수익률
    ann_ret = (1 + returns_series.mean()) ** 252 - 1
    # 연환산 변동성
    ann_vol = returns_series.std() * np.sqrt(252)
    # 샤프 비율 = (수익률 - 무위험수익률) / 변동성
    sharpe = (ann_ret - risk_free) / ann_vol
    # MDD (Maximum Drawdown) = 최고점 대비 최대 낙폭
    cum = (1 + returns_series).cumprod()
    mdd = ((cum - cum.cummax()) / cum.cummax()).min()

    return {
        'yearly return':   f'{ann_ret * 100:.1f}%',
        'yearly volatile': f'{ann_vol * 100:.1f}%',
        'MDD':             f'{mdd * 100:.1f}%',
        'sharpe':          f'{sharpe:.1f}',
    }

실행 결과:

ETF연간수익률변동성MDD샤프
KODEX 200215.0%36.6%-20.6%5.8
TIGER S&P50037.2%13.3%-5.7%2.5
KODEX 국고채10년-4.7%5.7%-8.0%-1.4
KODEX 골드선물51.8%31.1%-24.7%1.6

지표 해석:

  • 샤프 비율 > 1.0: 위험 대비 수익 양호
  • MDD: 투자 기간 중 가장 크게 손실 났을 때의 낙폭
  • 252: 연간 거래일 수 (연환산 계수)

6. LLM 인사이트 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
def generate_analysis_insight(metrics_df):
    response = ChatOpenAI(model='gpt-4o-mini', temperature=0.3, max_tokens=400).invoke([
        {'role': 'system', 'content': '당신은 금융 데이터 분석가입니다. 수치 기반으로 직관적인 인사이트를 제공합니다'},
        {'role': 'user', 'content': f"""아래 ETF 비교 데이터를 분석하여 투자 인사이트를 작성하세요.

        {metrics_df.to_string()}

        다음 형식으로 답하세요:
        1. 핵심 발견(3줄)
        2. 위험 요인(2줄)
        3. 분산 투자 제안(2줄)"""}
    ])
    return response.content

metrics_df.to_string() — DataFrame을 LLM에 텍스트로 넘길 때 사용. .to_csv(), .to_markdown() 도 활용 가능


7. 시계열 리포트 생성 함수

통계 지표 기반 (코드가 계산)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def generate_analysis_report(ticker_name, close_prices):
    returns = close_prices.pct_change().dropna()
    sma_20  = close_prices.rolling(20).mean().iloc[-1]
    sma_60  = close_prices.rolling(60).mean().iloc[-1]
    current = close_prices.iloc[-1]

    # 롤링 Z-score (이상 감지)
    roll_mean = returns.rolling(30).mean()
    roll_std  = returns.rolling(30).std()
    z_latest  = ((returns - roll_mean) / roll_std).iloc[-1]

    from scipy.stats import skew, kurtosis
    indicators = {
        '현재가': f'{current:,.0f}',
        '추세': '상승' if sma_20 > sma_60 else '하락',
        '이상 여부': '정상' if abs(z_latest) < 2 else '주의' if abs(z_latest) < 3 else '이상',
        'Skewness': f'{skew(returns.dropna()):.3f}',
        'Kurtosis': f'{kurtosis(returns.dropna()):.3f}',
    }
    # LLM에 indicators 전달 → 분석 요약 반환

LLM-only 방식 (원시 데이터를 LLM이 직접 분석)

1
2
3
4
5
6
7
8
9
10
11
def generate_analysis_report_llm_only(ticker_name, close_prices):
    recent_prices = close_prices.tail(120).tolist()
    prompt = f"""
    {ticker_name}의 종가 데이터: {recent_prices}
    - 추세(상승/하락/횡보)
    - 변동성 특징
    - 최근 이상 움직임 여부
    를 분석해 2~3문장으로 요약하세요.
    수치 계산은 직접 수행해서 근거 기반으로 설명하세요.
    투자 추천은 하지 마세요.
    """

LLM-only 방식은 편리하지만 수치 계산 오류 위험 있음 → 중요 수치는 코드로 계산 후 LLM에 전달하는 방식 권장


8. 데이터 병합 (pd.merge, pd.concat)

pd.concat — 인덱스(날짜) 기준 결합

1
2
3
4
5
combined = pd.concat([
    kodex['Close'].rename('kodex_close'),
    tiger['Close'].rename('tiger_close'),
    bond['Close'].rename('bond_close')
], axis=1)

날짜 불일치 처리 (reindex + ffill)

1
2
# tiger에 날짜 5개 누락된 경우
aligned = df_b_missing.reindex(df_a.index).ffill()

pd.merge — 공통 컬럼(ticker) 기준 결합

1
merged = pd.merge(meta, analysis, on='ticker', how='inner')

Join 종류:

1
2
3
4
inner join: 두 테이블에 모두 있는 행만 (교집합)
outer join: 한쪽에만 있어도 포함 (합집합)
left join:  왼쪽 테이블 기준 (오른쪽에 없으면 NaN)
right join: 오른쪽 테이블 기준

merge_all 파이프라인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def merge_all(price_dict, meta_df):
    # 1. 모든 ticker의 종가 합치기
    closes = pd.concat(
        {ticker: df['Close'] for ticker, df in price_dict.items()}, axis=1
    ).dropna()

    # 2. 연간 수익률 계산
    analysis_rows = []
    for ticker in closes.columns:
        ret = closes[ticker].pct_change().dropna()
        ann_ret = (1 + ret.mean()) ** 255 - 1
        analysis_rows.append({'ticker': ticker, 'annual_return': round(ann_ret * 100, 2)})

    # 3. meta + analysis 병합
    result = pd.merge(meta_df, pd.DataFrame(analysis_rows), on='ticker', how='inner')
    return result

9. Long Format 분석 (groupby, pivot_table)

Wide → Long 변환

1
2
3
4
5
6
7
8
9
10
11
long_data = []
for ticker, name in [('069500', 'KODEX200'), ...]:
    df = price_dict.get(ticker)
    ret = df['Close'].pct_change().dropna()
    for date, val in ret.items():
        long_data.append({
            'date': date, 'ticker': ticker, 'name': name,
            'return': val, 'weekday': date.strftime('%a'), 'month': date.month,
        })
long_df = pd.DataFrame(long_data)
# → (363, 6) — 3개 종목 × 121 거래일

groupby 집계

1
2
3
summary = long_df.groupby('name')['return'].agg(
    평균수익률='mean', 변동성='std', 최대='max', 최소='min', 거래일수='count'
)

실행 결과:

1
2
3
4
              평균수익률      변동성
KODEX200       0.5170    0.0307
KODEX_국고채    -0.0333    0.0045
TIGER_SP500    0.0836    0.0083

pivot_table — 월별 수익률 히트맵

1
pd.pivot_table(long_df, values='return', index='name', columns='month', aggfunc='mean')

stack / unstack

1
2
stacked = vol_pivot.stack()       # Wide → Long (Multi-index)
stacked.idxmax()                  # 가장 변동성 큰 (ETF명, 월) 조합

자주 나오는 실수 / 주의사항

  • cum_returns = (1 + returns).cumprod() 시작값: 첫 행이 1.0이 아닌 1 + 첫날 수익률이 됨 → 비교 기준일 맞춰야 함
  • 상관관계 ≠ 인과관계: 두 ETF 수익률이 비슷하게 움직여도 원인이 같을 수는 있지만 한쪽이 다른 쪽을 만드는 게 아님
  • resample('W').last(): 주 마지막 거래일 종가만 남겨 주별 데이터로 변환. mean() 쓰면 주간 평균가격이 됨
  • axis=1 vs axis=0: pd.concat(axis=1) → 열 방향 이어붙이기(날짜 인덱스 맞춰야 함), axis=0 → 행 방향 이어붙이기

[보충] statsmodels seasonal_decompose

1
2
3
4
5
6
7
8
from statsmodels.tsa.seasonal import seasonal_decompose

# period: 계절 주기 (주별 데이터, 분기 = 13주)
decomp = seasonal_decompose(weekly, model='additive', period=13)

decomp.trend.plot()    # 장기 추세선
decomp.seasonal.plot() # 반복 패턴
decomp.resid.plot()    # 설명 안 된 잔차

주의: period 파라미터 > 데이터 길이 / 2 이어야 함. 데이터가 짧으면 에러 발생


이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

인기 태그