import streamlit as st import pandas as pd import numpy as np from prophet import Prophet import plotly.express as px import matplotlib.pyplot as plt from datetime import date from pathlib import Path import matplotlib.font_manager as fm import matplotlib as mpl # ------------------------------------------------- # CONFIG ------------------------------------------ # ------------------------------------------------- CSV_PATH = Path("2025-domae.csv") # 파일 경로 수정 MACRO_START, MACRO_END = "1996-01-01", "2030-12-31" MICRO_START, MICRO_END = "2020-01-01", "2026-12-31" # 한글 폰트 설정 font_list = [f.name for f in fm.fontManager.ttflist if 'gothic' in f.name.lower() or 'gulim' in f.name.lower() or 'malgun' in f.name.lower() or 'nanum' in f.name.lower() or 'batang' in f.name.lower()] if font_list: font_name = font_list[0] plt.rcParams['font.family'] = font_name mpl.rcParams['axes.unicode_minus'] = False else: plt.rcParams['font.family'] = 'DejaVu Sans' st.set_page_config(page_title="품목별 가격 예측", page_icon="📈", layout="wide") # ------------------------------------------------- # UTILITIES --------------------------------------- # ------------------------------------------------- DATE_CANDIDATES = {"date", "ds", "ymd", "날짜", "prce_reg_mm", "etl_ldg_dt"} ITEM_CANDIDATES = {"item", "품목", "code", "category", "pdlt_nm", "spcs_nm"} PRICE_CANDIDATES = {"price", "y", "value", "가격", "avrg_prce"} def _standardize_columns(df: pd.DataFrame) -> pd.DataFrame: """Standardize column names to date/item/price and deduplicate.""" col_map = {} for c in df.columns: lc = c.lower() if lc in DATE_CANDIDATES: col_map[c] = "date" elif lc in PRICE_CANDIDATES: col_map[c] = "price" elif lc in ITEM_CANDIDATES: # first hit as item, second as species if "item" not in col_map.values(): col_map[c] = "item" else: col_map[c] = "species" df = df.rename(columns=col_map) # ── handle duplicated columns after rename ───────────────────────── if df.columns.duplicated().any(): df = df.loc[:, ~df.columns.duplicated()] # ── index datetime to column ─────────────────────────────────────── if "date" not in df.columns and df.index.dtype.kind == "M": df.reset_index(inplace=True) df.rename(columns={df.columns[0]: "date"}, inplace=True) # ── convert YYYYMM string to datetime ────────────────────────────── if "date" in df.columns and pd.api.types.is_object_dtype(df["date"]): if len(df) > 0: sample = str(df["date"].iloc[0]) if sample.isdigit() and len(sample) in (6, 8): df["date"] = pd.to_datetime(df["date"].astype(str).str[:6], format="%Y%m", errors="coerce") # ── build item from pdlt_nm + spcs_nm if needed ──────────────────── if "item" not in df.columns and {"pdlt_nm", "spcs_nm"}.issubset(df.columns): df["item"] = df["pdlt_nm"].str.strip() + "-" + df["spcs_nm"].str.strip() # ── merge item + species ─────────────────────────────────────────── if {"item", "species"}.issubset(df.columns): df["item"] = df["item"].astype(str).str.strip() + "-" + df["species"].astype(str).str.strip() df.drop(columns=["species"], inplace=True) return df @st.cache_data(show_spinner=False) def load_data() -> pd.DataFrame: """Load price data from CSV file.""" try: if not CSV_PATH.exists(): st.error(f"💾 {CSV_PATH} 파일을 찾을 수 없습니다.") st.stop() st.sidebar.info(f"{CSV_PATH} 파일에서 데이터를 불러옵니다.") # CSV 파일 직접 로드 df = pd.read_csv(CSV_PATH) st.sidebar.success(f"CSV 데이터 로드 완료: {len(df)}개 행") # 원본 데이터 형태 확인 st.sidebar.write("원본 데이터 컬럼:", list(df.columns)) df = _standardize_columns(df) st.sidebar.write("표준화 후 컬럼:", list(df.columns)) missing = {c for c in ["date", "item", "price"] if c not in df.columns} if missing: st.error(f"필수 컬럼 누락: {', '.join(missing)} — 파일 컬럼명을 확인하세요.") st.stop() # 날짜 변환 before_date_convert = len(df) df["date"] = pd.to_datetime(df["date"], errors="coerce") after_date_convert = df.dropna(subset=["date"]).shape[0] if before_date_convert != after_date_convert: st.warning(f"날짜 변환 중 {before_date_convert - after_date_convert}개 행이 제외되었습니다.") # NA 데이터 처리 before_na_drop = len(df) df = df.dropna(subset=["date", "item", "price"]) after_na_drop = len(df) if before_na_drop != after_na_drop: st.warning(f"NA 제거 중 {before_na_drop - after_na_drop}개 행이 제외되었습니다.") df.sort_values("date", inplace=True) # 데이터 날짜 범위 확인 if len(df) > 0: st.sidebar.write(f"데이터 날짜 범위: {df['date'].min().strftime('%Y-%m-%d')} ~ {df['date'].max().strftime('%Y-%m-%d')}") st.sidebar.write(f"총 품목 수: {df['item'].nunique()}") else: st.error("유효한 데이터가 없습니다!") return df except Exception as e: st.error(f"데이터 로드 중 오류 발생: {str(e)}") # 오류 상세 정보 표시 import traceback st.code(traceback.format_exc()) st.stop() @st.cache_data(show_spinner=False) def get_items(df: pd.DataFrame): return sorted(df["item"].unique()) @st.cache_data(show_spinner=False, ttl=3600) def fit_prophet(df: pd.DataFrame, horizon_end: str): # Make a copy and ensure we have data df = df.copy() df = df.dropna(subset=["date", "price"]) # 중복 날짜 처리 - 동일 날짜에 여러 값이 있으면 평균값 사용 df = df.groupby("date")["price"].mean().reset_index() if len(df) < 2: st.warning(f"데이터 포인트가 부족합니다. 예측을 위해서는 최소 2개 이상의 유효 데이터가 필요합니다. (현재 {len(df)}개)") return None, None # Convert to Prophet format prophet_df = df.rename(columns={"date": "ds", "price": "y"}) try: # Fit the model m = Prophet(yearly_seasonality=True, weekly_seasonality=False, daily_seasonality=False) m.fit(prophet_df) # Generate future dates periods = max((pd.Timestamp(horizon_end) - df["date"].max()).days, 1) future = m.make_future_dataframe(periods=periods, freq="D") # Make predictions forecast = m.predict(future) return m, forecast except Exception as e: st.error(f"Prophet 모델 생성 중 오류: {str(e)}") return None, None # ------------------------------------------------- # LOAD DATA --------------------------------------- # ------------------------------------------------- raw_df = load_data() if len(raw_df) == 0: st.error("데이터가 비어 있습니다. 파일을 확인해주세요.") st.stop() st.sidebar.header("🔍 품목 선택") selected_item = st.sidebar.selectbox("품목", get_items(raw_df)) current_date = date.today() st.sidebar.caption(f"오늘: {current_date}") item_df = raw_df.query("item == @selected_item").copy() if item_df.empty: st.error("선택한 품목 데이터 없음") st.stop() # ------------------------------------------------- # MACRO FORECAST 1996‑2030 ------------------------ # ------------------------------------------------- st.header(f"📈 {selected_item} 가격 예측 대시보드") # 데이터 필터링 로직 개선 try: macro_start_dt = pd.Timestamp(MACRO_START) # 데이터가 충분하지 않으면 시작 날짜를 조정 if len(item_df[item_df["date"] >= macro_start_dt]) < 10: # 가장 오래된 날짜부터 시작 macro_start_dt = item_df["date"].min() st.info(f"충분한 데이터가 없어 시작 날짜를 {macro_start_dt.strftime('%Y-%m-%d')}로 조정했습니다.") macro_df = item_df[item_df["date"] >= macro_start_dt].copy() except Exception as e: st.error(f"날짜 필터링 오류: {str(e)}") macro_df = item_df.copy() # 필터링 없이 전체 데이터 사용 # Add diagnostic info with st.expander("데이터 진단"): st.write(f"- 전체 데이터 수: {len(item_df)}") st.write(f"- 분석 데이터 수: {len(macro_df)}") if len(macro_df) > 0: st.write(f"- 기간: {macro_df['date'].min().strftime('%Y-%m-%d')} ~ {macro_df['date'].max().strftime('%Y-%m-%d')}") st.dataframe(macro_df.head()) else: st.write("데이터가 없습니다.") if len(macro_df) < 2: st.warning(f"{selected_item}에 대한 데이터가 충분하지 않습니다. 전체 기간 데이터를 표시합니다.") fig = px.line(item_df, x="date", y="price", title=f"{selected_item} 과거 가격") st.plotly_chart(fig, use_container_width=True) else: try: with st.spinner("장기 예측 모델 생성 중..."): m_macro, fc_macro = fit_prophet(macro_df, MACRO_END) if m_macro is not None and fc_macro is not None: fig_macro = px.line(fc_macro, x="ds", y="yhat", title="장기 예측 (1996–2030)") fig_macro.add_scatter(x=macro_df["date"], y=macro_df["price"], mode="lines", name="실제 가격") st.plotly_chart(fig_macro, use_container_width=True) latest_price = macro_df.iloc[-1]["price"] # 2030년 마지막 날 찾기 target_date = pd.Timestamp(MACRO_END) close_dates = fc_macro.loc[(fc_macro["ds"] - target_date).abs().argsort()[:1], "ds"].values[0] macro_pred = fc_macro.loc[fc_macro["ds"] == close_dates, "yhat"].iloc[0] macro_pct = (macro_pred - latest_price) / latest_price * 100 st.metric("2030 예측가", f"{macro_pred:,.0f}", f"{macro_pct:+.1f}%") else: st.warning("예측 모델을 생성할 수 없습니다.") fig = px.line(item_df, x="date", y="price", title=f"{selected_item} 과거 가격") st.plotly_chart(fig, use_container_width=True) except Exception as e: st.error(f"장기 예측 오류 발생: {str(e)}") fig = px.line(item_df, x="date", y="price", title=f"{selected_item} 과거 가격") st.plotly_chart(fig, use_container_width=True) # ------------------------------------------------- # MICRO FORECAST 2024‑2026 ------------------------ # ------------------------------------------------- st.subheader("🔎 2024–2026 단기 예측") # 데이터 필터링 로직 개선 try: micro_start_dt = pd.Timestamp(MICRO_START) # 데이터가 충분하지 않으면 시작 날짜를 조정 if len(item_df[item_df["date"] >= micro_start_dt]) < 10: # 최근 30% 데이터만 사용 n = max(2, int(len(item_df) * 0.3)) micro_df = item_df.sort_values("date").tail(n).copy() st.info(f"충분한 최근 데이터가 없어 최근 {n}개 데이터 포인트만 사용합니다.") else: micro_df = item_df[item_df["date"] >= micro_start_dt].copy() except Exception as e: st.error(f"단기 예측 데이터 필터링 오류: {str(e)}") # 최근 10개 데이터 포인트 사용 micro_df = item_df.sort_values("date").tail(10).copy() if len(micro_df) < 2: st.warning(f"{MICRO_START} 이후 데이터가 충분하지 않습니다.") fig = px.line(item_df, x="date", y="price", title=f"{selected_item} 최근 가격") st.plotly_chart(fig, use_container_width=True) else: try: with st.spinner("단기 예측 모델 생성 중..."): m_micro, fc_micro = fit_prophet(micro_df, MICRO_END) if m_micro is not None and fc_micro is not None: fig_micro = px.line(fc_micro, x="ds", y="yhat", title="단기 예측 (2024–2026)") fig_micro.add_scatter(x=micro_df["date"], y=micro_df["price"], mode="lines", name="실제 가격") st.plotly_chart(fig_micro, use_container_width=True) latest_price = micro_df.iloc[-1]["price"] target_date = pd.Timestamp(MICRO_END) close_dates = fc_micro.loc[(fc_micro["ds"] - target_date).abs().argsort()[:1], "ds"].values[0] micro_pred = fc_micro.loc[fc_micro["ds"] == close_dates, "yhat"].iloc[0] micro_pct = (micro_pred - latest_price) / latest_price * 100 st.metric("2026 예측가", f"{micro_pred:,.0f}", f"{micro_pct:+.1f}%") else: st.warning("단기 예측 모델을 생성할 수 없습니다.") except Exception as e: st.error(f"단기 예측 오류: {str(e)}") # ------------------------------------------------- # SEASONALITY & PATTERN --------------------------- # ------------------------------------------------- with st.expander("📆 시즈널리티 & 패턴 설명"): if 'm_micro' in locals() and m_micro is not None and 'fc_micro' in locals() and fc_micro is not None: try: comp_fig = m_micro.plot_components(fc_micro) st.pyplot(comp_fig) month_season = (fc_micro[["ds", "yearly"]] .assign(month=lambda d: d.ds.dt.month) .groupby("month")["yearly"].mean()) st.markdown( f"**연간 피크 월:** {int(month_season.idxmax())}월 \n" f"**연간 저점 월:** {int(month_season.idxmin())}월 \n" f"**연간 변동폭:** {month_season.max() - month_season.min():.1f}") except Exception as e: st.error(f"시즈널리티 분석 오류: {str(e)}") else: st.info("패턴 분석을 위한 충분한 데이터가 없습니다.") # ------------------------------------------------- # FOOTER ------------------------------------------ # ------------------------------------------------- st.markdown("---") st.caption("© 2025 품목별 가격 예측 시스템 | 데이터 분석 자동화")