목차
- 이동평균 시각화 (SMA, EMA)
- 트렌드 분해 (seasonal_decompose)
- 다중 ETF 누적 수익률 비교
- 상관관계 분석
- 성과 지표 계산 (수익률/변동성/MDD/샤프)
- LLM 인사이트 생성
- 시계열 리포트 생성 함수
- 데이터 병합 (pd.merge, pd.concat)
- Long Format 분석 (groupby, pivot_table)
- 자주 나오는 실수 / 주의사항
- [보충] 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 200 | 215.0% | 36.6% | -20.6% | 5.8 |
| TIGER S&P500 | 37.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
|
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 이어야 함. 데이터가 짧으면 에러 발생
끝