포스트

2026-04-15 강의 정리

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

2026-04-15 강의 정리

시계열 리포트는 계산 결과와 그 결과를 설명하는 문장을 구분해서 만들어야 한다. 이동평균과 성과 지표를 계산한 뒤 LLM에는 계산된 값과 기준을 전달하는 흐름으로 살펴본다. 같은 이름의 지표라도 기간과 산식이 다르면 직접 비교하기 어렵다는 점을 전제로 한다.

목차

  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
  12. 리포트의 수치와 설명을 분리하기

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 인사이트 생성

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 이어야 함. 데이터가 짧으면 에러 발생


12. 리포트의 수치와 설명을 분리하기

리포트에서 다시 계산할 수 있어야 하는 부분은 코드로 남긴다. LLM이 생성한 문장은 지표를 설명하는 보조 결과로 두고, 표의 값과 문장의 수치가 같은 원본에서 나왔는지 확인한다.

데이터를 병합한 뒤에는 날짜 교집합과 누락 행부터 살펴본다. 비교 기간이 달라진 상태에서 지표만 나란히 놓으면 구현은 정상이어도 해석이 어긋날 수 있으므로, 결과 표에 사용 기간을 함께 표시하는 편이 낫다.

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

인기 태그