|
| 1 | +import os |
| 2 | +import time |
| 3 | +import warnings |
| 4 | + |
| 5 | +import matplotlib.pyplot as plt |
| 6 | +import numpy as np |
| 7 | +import korean_font # noqa: F401 |
| 8 | +from statsmodels.tsa.arima.model import ARIMA |
| 9 | +from statsmodels.tsa.stattools import adfuller |
| 10 | + |
| 11 | +warnings.filterwarnings("ignore") |
| 12 | +os.makedirs("result", exist_ok=True) |
| 13 | + |
| 14 | +print("=" * 60) |
| 15 | +print(" ARIMA 모델: 주가 시계열 예측 실습") |
| 16 | +print("=" * 60) |
| 17 | +print(" ARIMA(p, d, q)") |
| 18 | +print(" p = AR 차수: 몇 일 전까지 자기 자신의 영향을 받는가") |
| 19 | +print(" d = 차분 횟수: 정상성을 만들기 위해 몇 번 뺄셈하는가") |
| 20 | +print(" q = MA 차수: 몇 일 전까지 과거 오차의 영향을 받는가") |
| 21 | + |
| 22 | +# ── 1. 데이터 로드 ───────────────────────────────────────── |
| 23 | +print("\n[1/7] 주가 데이터 로드 중...") |
| 24 | +time.sleep(0.5) |
| 25 | +np.random.seed(42) |
| 26 | +TICKER = '078935.KS' |
| 27 | +prices = None |
| 28 | +try: |
| 29 | + import yfinance as yf |
| 30 | + from datetime import date |
| 31 | + df = yf.download(TICKER, start='2020-01-01', end=date.today().isoformat(), |
| 32 | + auto_adjust=True, progress=False) |
| 33 | + if len(df) > 50: |
| 34 | + prices = df['Close'].squeeze().dropna().values.flatten().astype(np.float32) |
| 35 | + print(f" ✓ {TICKER}: {len(prices)}일 실제 데이터 로드") |
| 36 | +except Exception as e: |
| 37 | + print(f" yfinance 오류 ({e}) → 가상 데이터 사용") |
| 38 | + |
| 39 | +if prices is None: |
| 40 | + days = 200 |
| 41 | + t = np.arange(days) |
| 42 | + prices = (100 + 0.1 * t + 3 * np.sin(t / 15) + np.random.normal(0, 1.2, days)).astype(np.float32) |
| 43 | + print(f" → 가상 {days}일치 주가 생성") |
| 44 | + |
| 45 | +days = len(prices) |
| 46 | +split_idx = int(days * 0.8) |
| 47 | +train_prices = prices[:split_idx] |
| 48 | +test_prices = prices[split_idx:] |
| 49 | +print(f" → 학습 데이터: {len(train_prices)}일 | 테스트(예측 대상): {len(test_prices)}일") |
| 50 | +time.sleep(0.3) |
| 51 | + |
| 52 | +# ── 2. 정상성 검정 ───────────────────────────────────────── |
| 53 | +print("\n[2/7] ADF 정상성 검정 중...") |
| 54 | +print(" 가설: 'H0 = 비정상 시계열이다' → p < 0.05 이면 H0 기각 = 정상") |
| 55 | +time.sleep(0.5) |
| 56 | +adf_raw = adfuller(train_prices) |
| 57 | +adf_diff = adfuller(np.diff(train_prices)) |
| 58 | +print(f" 원본 주가 p-value: {adf_raw[1]:.4f} → {'비정상 → 차분 필요' if adf_raw[1] > 0.05 else '정상'}") |
| 59 | +print(f" 1차 차분 p-value: {adf_diff[1]:.4f} → {'비정상' if adf_diff[1] > 0.05 else '정상 → d=1 확정'}") |
| 60 | +d = 1 if adf_raw[1] > 0.05 else 0 |
| 61 | +print(f" → 차분 횟수(d) = {d}") |
| 62 | +time.sleep(0.4) |
| 63 | + |
| 64 | +# ── 3. p, q 결정 ────────────────────────────────────────── |
| 65 | +print("\n[3/7] ARIMA 차수 결정 중 (p=2, d=1, q=2 사용)...") |
| 66 | +print(" 방법: ACF로 q, PACF로 p를 추정합니다") |
| 67 | +print(" 이 실습에서는 주가 예측 일반적 조합 ARIMA(2,1,2)를 사용합니다") |
| 68 | +p, q = 2, 2 |
| 69 | +print(f" → 최종 설정: ARIMA({p}, {d}, {q})") |
| 70 | +time.sleep(0.5) |
| 71 | + |
| 72 | +# ── 4. 모델 학습 ────────────────────────────────────────── |
| 73 | +print("\n[4/7] ARIMA 모델 학습 중...") |
| 74 | +print(" AR(자기회귀): 과거 주가가 오늘 주가에 미치는 영향 학습") |
| 75 | +print(" MA(이동평균): 과거 예측 오차가 오늘에 미치는 영향 학습") |
| 76 | +time.sleep(0.8) |
| 77 | +model = ARIMA(train_prices, order=(p, d, q)) |
| 78 | +fitted = model.fit() |
| 79 | +print(f" → 학습 완료!") |
| 80 | +print(f" AIC: {fitted.aic:.2f} (작을수록 더 좋은 모델)") |
| 81 | +print(f" BIC: {fitted.bic:.2f}") |
| 82 | +time.sleep(0.5) |
| 83 | + |
| 84 | +# ── 5. 학습 데이터 내 적합값 ────────────────────────────── |
| 85 | +print("\n[5/7] 학습 기간 내 적합값(fitted values) 확인 중...") |
| 86 | +time.sleep(0.4) |
| 87 | +in_sample_pred = fitted.fittedvalues |
| 88 | +residuals = train_prices[1:] - in_sample_pred[1:] |
| 89 | +rmse_train = np.sqrt(np.mean(residuals ** 2)) |
| 90 | +print(f" → 학습 기간 RMSE: {rmse_train:.4f}원") |
| 91 | +time.sleep(0.3) |
| 92 | + |
| 93 | +# ── 6. 미래 예측 ────────────────────────────────────────── |
| 94 | +print("\n[6/7] 테스트 구간 예측 중 (40일)...") |
| 95 | +print(" 방법: 1일씩 예측 후 실제값을 반영해 다음 예측 (워킹 포워드)") |
| 96 | +time.sleep(0.5) |
| 97 | +history = list(train_prices) |
| 98 | +one_step_preds = [] |
| 99 | +for i in range(len(test_prices)): |
| 100 | + m = ARIMA(history, order=(p, d, q)) |
| 101 | + f = m.fit() |
| 102 | + yhat = f.forecast(steps=1)[0] |
| 103 | + one_step_preds.append(yhat) |
| 104 | + history.append(test_prices[i]) |
| 105 | + if (i + 1) % 10 == 0: |
| 106 | + print(f" {i + 1}/{len(test_prices)}일 예측 완료...") |
| 107 | + time.sleep(0.2) |
| 108 | +one_step_preds = np.array(one_step_preds) |
| 109 | +rmse_test = np.sqrt(np.mean((test_prices - one_step_preds) ** 2)) |
| 110 | +mae_test = np.mean(np.abs(test_prices - one_step_preds)) |
| 111 | +print(f" → 테스트 RMSE: {rmse_test:.4f}원 | MAE: {mae_test:.4f}원") |
| 112 | +time.sleep(0.3) |
| 113 | + |
| 114 | +# ── 7. 시각화 ───────────────────────────────────────────── |
| 115 | +print("\n[7/7] 시각화 저장 중...") |
| 116 | +time.sleep(0.5) |
| 117 | + |
| 118 | +fig, axes = plt.subplots(2, 1, figsize=(12, 8)) |
| 119 | + |
| 120 | +# 전체 그래프 요약 제목 |
| 121 | +fig.text(0.5, 0.98, '과거 주가의 패턴을 수식으로 정리해 미래를 예측합니다 (ARIMA)', |
| 122 | + ha='center', fontsize=9, color='#333', weight='bold') |
| 123 | + |
| 124 | +# 전체 구간 |
| 125 | +ax = axes[0] |
| 126 | +ax.plot(range(days), prices, color='steelblue', linewidth=1.2, label='실제 주가', alpha=0.8) |
| 127 | +ax.plot(range(1, len(in_sample_pred)), in_sample_pred[1:], |
| 128 | + color='orange', linewidth=1.0, linestyle='--', label='ARIMA 적합값(학습)') |
| 129 | +ax.plot(range(split_idx, days), one_step_preds, |
| 130 | + color='tomato', linewidth=1.5, label='ARIMA 예측(테스트)') |
| 131 | +ax.axvline(split_idx, color='gray', linestyle=':', linewidth=1.5, label='학습/테스트 경계') |
| 132 | +ax.set_title(f"ARIMA({p},{d},{q}) 주가 예측 | 테스트 RMSE={rmse_test:.2f}") |
| 133 | +ax.set_xlabel("거래일") |
| 134 | +ax.set_ylabel("주가") |
| 135 | +ax.legend(fontsize=9) |
| 136 | +# 학습 구간 설명 |
| 137 | +ax.text(0.35, 0.12, '컴퓨터가 공부한 구간', transform=ax.transAxes, |
| 138 | + ha='center', fontsize=8, color='steelblue', |
| 139 | + bbox=dict(boxstyle='round,pad=0.3', facecolor='lightblue', alpha=0.4)) |
| 140 | +# 테스트 구간 설명 |
| 141 | +ax.text(0.88, 0.12, '실제로 예측해본 구간\n(답을 보여주지 않음)', transform=ax.transAxes, |
| 142 | + ha='center', fontsize=8, color='tomato', |
| 143 | + bbox=dict(boxstyle='round,pad=0.3', facecolor='mistyrose', alpha=0.4)) |
| 144 | +# 세로 점선 화살표 어노테이션 |
| 145 | +ax.annotate('여기서부터 진짜 예측 시작!', xy=(0.80, 0.60), xytext=(0.65, 0.78), |
| 146 | + xycoords='axes fraction', textcoords='axes fraction', |
| 147 | + arrowprops=dict(arrowstyle='->', color='gray'), fontsize=7, color='#333') |
| 148 | +# 예측선 설명 |
| 149 | +ax.annotate('ARIMA 예측값', xy=(0.88, 0.75), xytext=(0.70, 0.90), |
| 150 | + xycoords='axes fraction', textcoords='axes fraction', |
| 151 | + arrowprops=dict(arrowstyle='->', color='tomato'), fontsize=7, color='darkred') |
| 152 | +ax.text(0.5, -0.12, 'ARIMA = 자기회귀(AR) + 차분(I) + 이동평균오차(MA)의 조합 모델', |
| 153 | + transform=ax.transAxes, ha='center', fontsize=7, color='gray') |
| 154 | + |
| 155 | +# 테스트 구간 확대 |
| 156 | +ax2 = axes[1] |
| 157 | +ax2.plot(range(len(test_prices)), test_prices, |
| 158 | + color='steelblue', linewidth=1.5, label='실제 주가') |
| 159 | +ax2.plot(range(len(test_prices)), one_step_preds, |
| 160 | + color='tomato', linewidth=1.5, linestyle='--', label='예측 주가') |
| 161 | +ax2.fill_between(range(len(test_prices)), |
| 162 | + test_prices, one_step_preds, alpha=0.2, color='gray', label='오차 구간') |
| 163 | +ax2.set_title("테스트 구간 확대 (실제 vs 예측)") |
| 164 | +ax2.set_xlabel("테스트 인덱스") |
| 165 | +ax2.set_ylabel("주가") |
| 166 | +ax2.legend(fontsize=9) |
| 167 | +# 회색 띠 설명 |
| 168 | +ax2.annotate('이 범위 안에 실제값이 있을 거라는 오차 구간', |
| 169 | + xy=(0.5, 0.5), xytext=(0.55, 0.25), |
| 170 | + xycoords='axes fraction', textcoords='axes fraction', |
| 171 | + arrowprops=dict(arrowstyle='->', color='gray'), fontsize=7, color='#333') |
| 172 | +# 오차 설명 |
| 173 | +ax2.annotate('이 차이가 오차예요', |
| 174 | + xy=(0.25, 0.65), xytext=(0.05, 0.85), |
| 175 | + xycoords='axes fraction', textcoords='axes fraction', |
| 176 | + arrowprops=dict(arrowstyle='->', color='gray'), fontsize=7, color='#333') |
| 177 | +ax2.text(0.5, -0.12, '실제선(파랑)과 예측선(빨강)이 가까울수록 예측이 잘 된 것입니다', |
| 178 | + transform=ax2.transAxes, ha='center', fontsize=7, color='gray') |
| 179 | + |
| 180 | +plt.tight_layout() |
| 181 | +plt.subplots_adjust(top=0.93) |
| 182 | +ticker_tag = TICKER.replace('.', '_') |
| 183 | +plt.savefig(f"result/ArimaStockForecast_{ticker_tag}.png", dpi=150, bbox_inches="tight") |
| 184 | +print(f" → 그래프 저장: result/ArimaStockForecast_{ticker_tag}.png") |
| 185 | + |
| 186 | +print("\n✓ ARIMA 주가 예측 실습 완료!\n") |
0 commit comments