ever-flow/visualization_modules
0
1import streamlit as st2import pandas as pd3import numpy as np4import plotly.graph_objects as go5 6from config import PERIODS7from aggregation import compute_aggregate8 9 10def show_heatmap(DF_RAW):11 st.header("EMSEC × EMTEC Heatmap")12 # —--- Sidebar Controls (unchanged) --------------------------------------13 with st.sidebar:14 st.markdown("**기준 연도**")15 year_sel = st.selectbox("", PERIODS, label_visibility="collapsed", key="year_sel1")16 17 st.markdown("**상장시장**")18 market_options = [19 "전체", "한국 전체", "KOSPI", "KOSDAQ", "미국 전체", "NASDAQ",20 "일본 전체", "Prime (Domestic Stocks)", "Standard (Domestic Stocks)", "Prime (Foreign Stocks)",21 ]22 market_sel = st.selectbox("", market_options, label_visibility="collapsed", key="market_sel1")23 country_filter = market_filter = None24 if "한국" in market_sel:25 country_filter = "한국"26 elif "미국" in market_sel:27 country_filter = "미국"28 elif "일본" in market_sel:29 country_filter = "일본"30 if market_sel not in ["전체", "한국 전체", "미국 전체", "일본 전체"]:31 market_filter = market_sel32 33 # Row (EMSEC)34 st.markdown("**Sector > Industry**")35 available_sectors = sorted([s for s in DF_RAW.Sector.unique() if pd.notna(s) and s != 'Unclassified'])36 sector_sel = st.selectbox("", ["전체"] + available_sectors, label_visibility="collapsed", key="sector_sel1")37 if sector_sel == "전체":38 industry_pool = sorted([i for i in DF_RAW.Industry.unique() if pd.notna(i) and i != 'Unclassified'])39 else:40 industry_pool = sorted([i for i in DF_RAW.loc[DF_RAW.Sector == sector_sel, "Industry"].unique() if pd.notna(i) and i != 'Unclassified'])41 industry_sel = st.selectbox("", ["전체"] + industry_pool, label_visibility="collapsed", key="industry_sel1")42 43 # Column (EMTEC)44 st.markdown("**Theme > Technology**")45 available_themes = sorted([t for t in DF_RAW.Theme.unique() if pd.notna(t) and t != 'Unclassified'])46 theme_sel = st.selectbox("", ["전체"] + available_themes, label_visibility="collapsed", key="theme_sel1")47 if theme_sel == "전체":48 tech_pool = sorted([t for t in DF_RAW.Technology.unique() if pd.notna(t) and t != 'Unclassified'])49 else:50 tech_pool = sorted([t for t in DF_RAW.loc[DF_RAW.Theme == theme_sel, "Technology"].unique() if pd.notna(t) and t != 'Unclassified'])51 tech_sel = st.selectbox("", ["전체"] + tech_pool, label_visibility="collapsed", key="tech_sel1")52 53 st.markdown("**계측값 선택**")54 group_sel = st.selectbox("", ["기업", "비교가치 멀티플", "재무비율"], label_visibility="collapsed", key="group_sel1")55 metric_main = metric_mode = base_col = agg_func = None56 allow_subtotal = True57 if group_sel == "기업":58 corp_first = st.selectbox("", ["기업수", "0이하 비율"], label_visibility="collapsed", key="corp_first1")59 if corp_first == "기업수":60 metric_main = "기업수"61 metric_mode = st.selectbox("", ["결측 포함", "결측 미포함", "결측 비율"], label_visibility="collapsed", key="metric_mode1")62 else:63 metric_main = "0이하비율"64 base_map = {"순이익": "Net_Income", "EBITDA": "EBITDA", "매출": "Sales", "자산총계": "Assets", "순자산": "Book"}65 base_sel = st.selectbox("", list(base_map.keys()), label_visibility="collapsed", key="corp_base1")66 base_col = base_map[base_sel]67 metric_mode = st.selectbox("", ["결측 포함", "결측 제외"], label_visibility="collapsed", key="metric_mode2")68 elif group_sel == "비교가치 멀티플":69 metric_main = st.selectbox("", ["PER", "PBR", "EV_EBITDA"], label_visibility="collapsed", key="metric_main1")70 agg_func = st.selectbox("", ["AVG", "HRM", "MED", "AGG"], label_visibility="collapsed", key="agg_func1")71 allow_subtotal = agg_func == "AGG"72 else:73 metric_main = st.selectbox("", ["ROE", "영업이익률", "EBITDA/Sales", "총자산이익률", "자산회전율", "자기자본비율", "부채비율", "시가총액/매출액", "시가총액/영업이익"], label_visibility="collapsed", key="metric_main2")74 agg_func = st.selectbox("", ["AVG", "HRM", "MED", "AGG"], label_visibility="collapsed", key="agg_func2")75 allow_subtotal = agg_func == "AGG"76 77 # —--- DataFrame Filtering ------------------------------------------------78 DF = DF_RAW[DF_RAW.Year == year_sel].copy()79 non_financial_keywords = ['EMSEC', 'EMTEC', 'ticker', 'market', 'Country', 'Market', 'name', 'Company']80 fin_cols = [c for c in DF.columns if not any(k in c for k in non_financial_keywords)]81 company_missing = DF.groupby('ticker')[fin_cols].apply(lambda x: x.isnull().values.any())82 DF['has_missing_financials'] = DF['ticker'].map(company_missing)83 84 if country_filter: DF = DF[DF.Country == country_filter]85 if market_filter: DF = DF[DF.Market == market_filter]86 if sector_sel != "전체": DF = DF[DF.Sector == sector_sel]87 if industry_sel != "전체": DF = DF[DF.Industry == industry_sel]88 if theme_sel != "전체": DF = DF[DF.Theme == theme_sel]89 if tech_sel != "전체": DF = DF[DF.Technology == tech_sel]90 91 # Index levels -----------------------------------------------------------92 row_index = "Sector" if sector_sel == "전체" else "Industry" if industry_sel == "전체" else "Sub_industry"93 col_index = "Theme" if theme_sel == "전체" else "Technology" if tech_sel == "전체" else "Sub_Technology"94 95 values_col = (base_col if group_sel == "기업" and metric_main == "0이하비율" else metric_main if group_sel != "기업" else "Company")96 if group_sel != "기업" and values_col not in DF.columns:97 st.warning(f"'{values_col}' 지표를 계산할 수 없습니다. 데이터나 설정을 확인해주세요.")98 st.stop()99 if DF.empty:100 st.warning("조건에 맞는 데이터가 없습니다.")101 st.stop()102 103 # Pivot (main values & counts) -------------------------------------------104 pivot_main = DF.groupby([row_index, col_index]).apply(105 lambda g: compute_aggregate(g, values_col, agg_func, year_sel, group_sel, metric_main, metric_mode, base_col)106 ).unstack(fill_value=np.nan)107 pivot_counts = DF.groupby([row_index, col_index])['Company'].nunique().unstack(fill_value=0)108 109 if pivot_main.empty:110 st.warning("피벗 테이블을 생성할 수 없습니다.")111 st.stop()112 113 x_orig, y_orig = pivot_main.columns.tolist(), pivot_main.index.tolist()114 z_core = pivot_main.values115 cnt_core = pivot_counts.reindex(index=y_orig, columns=x_orig).fillna(0).values116 117 # Subtotals/GrandTotals ---------------------------------------------------118 if allow_subtotal:119 row_tot = DF.groupby(row_index).apply(lambda g: compute_aggregate(g, values_col, agg_func, year_sel, group_sel, metric_main, metric_mode, base_col)).reindex(y_orig)120 col_tot = DF.groupby(col_index).apply(lambda g: compute_aggregate(g, values_col, agg_func, year_sel, group_sel, metric_main, metric_mode, base_col)).reindex(x_orig)121 grand_tot = compute_aggregate(DF, values_col, agg_func, year_sel, group_sel, metric_main, metric_mode, base_col)122 row_cnt = DF.groupby(row_index)['Company'].nunique().reindex(y_orig)123 col_cnt = DF.groupby(col_index)['Company'].nunique().reindex(x_orig)124 grand_cnt = DF['Company'].nunique()125 126 x_labels = ["Subtotal"] + x_orig127 y_labels = ["Subtotal"] + y_orig128 sz = (len(y_labels), len(x_labels))129 z_main, cnt_main = np.full(sz, np.nan), np.full(sz, 0)130 z_main[1:, 1:], cnt_main[1:, 1:] = z_core, cnt_core131 z_sub, cnt_sub = np.full(sz, np.nan), np.full(sz, 0)132 z_sub[0, 1:], z_sub[1:, 0] = col_tot.values, row_tot.values133 cnt_sub[0, 1:], cnt_sub[1:, 0] = col_cnt.values, row_cnt.values134 z_grd, cnt_grd = np.full(sz, np.nan), np.full(sz, 0)135 z_grd[0, 0], cnt_grd[0, 0] = grand_tot, grand_cnt136 z_comb = np.where(np.isnan(z_main), z_sub, z_main)137 z_comb[0, 0] = grand_tot138 else:139 x_labels, y_labels = x_orig, y_orig140 z_main, cnt_main = z_core, cnt_core141 z_comb, z_sub, z_grd, cnt_sub, cnt_grd = z_main, None, None, None, None142 143 # Value formatter ---------------------------------------------------------144 if group_sel == "기업":145 if metric_main == "기업수":146 fmt = (lambda v: f"{v:,}" if pd.notna(v) else "") if metric_mode in ["결측 포함", "결측 미포함"] else (lambda v: f"{v*100:.1f}%" if pd.notna(v) else "")147 else:148 fmt = lambda v: f"{v*100:.1f}%" if pd.notna(v) else ""149 elif metric_main in ["PER", "PBR", "EV_EBITDA", "시가총액/매출액", "시가총액/영업이익", "자산회전율"]:150 fmt = lambda v: f"{v:,.2f}x" if pd.notna(v) else ""151 elif metric_main in ["ROE", "영업이익률", "EBITDA/Sales", "총자산이익률", "자기자본비율", "부채비율"] and agg_func != 'AGG':152 fmt = lambda v: f"{v*100:.1f}%" if pd.notna(v) else ""153 else:154 fmt = lambda v: f"${v:,.0f}" if pd.notna(v) else ""155 156 txt = [[fmt(v) for v in row] for row in z_comb]157 158 # Plotly Heatmap ----------------------------------------------------------159 MAIN_CS = ["#e3f2fd", "#bbdefb", "#90caf9", "#64b5f6", "#42a5f5", "#2196f3", "#1e88e5", "#1976d2", "#1565c0", "#0d47a1"]160 SUB_CS = ["#f0f0f0", "#d9d9d9", "#bdbdbd", "#969696", "#737373", "#525252"]161 GT_CS = [[0, "#000000"], [1, "#000000"]]162 163 fig = go.Figure()164 fig.add_trace(go.Heatmap(z=z_main, x=x_labels, y=y_labels, colorscale=MAIN_CS,165 colorbar=dict(title=metric_main), customdata=cnt_main,166 hovertemplate="%{y} / %{x}<br>값: %{z:,.3f}<br>기업수: %{customdata:,}", xgap=1, ygap=1, hoverongaps=False))167 if allow_subtotal:168 fig.add_trace(go.Heatmap(z=z_sub, x=x_labels, y=y_labels, colorscale=SUB_CS,169 showscale=False, customdata=cnt_sub,170 hovertemplate="%{y} / %{x}<br>Subtotal: %{z:,.3f}<br>기업수: %{customdata:,}", xgap=1, ygap=1, hoverongaps=False))171 fig.add_trace(go.Heatmap(z=z_grd, x=x_labels, y=y_labels, colorscale=GT_CS,172 showscale=False, customdata=cnt_grd,173 hovertemplate="Grand Total<br>값: %{z:,.3f}<br>기업수: %{customdata:,}", xgap=1, ygap=1, hoverongaps=False))174 175 # Annotations ------------------------------------------------------------176 annotations = []177 for r, row in enumerate(z_comb):178 for c, val in enumerate(row):179 if pd.isna(val):180 continue181 is_grand = allow_subtotal and r == 0 and c == 0182 color = "white" if is_grand else "black"183 annotations.append(dict(text=txt[r][c], x=x_labels[c], y=y_labels[r], showarrow=False, font=dict(color=color)))184 185 fig.update_layout(annotations=annotations,186 height=max(650, 35*len(y_labels)),187 margin=dict(l=40, r=40, t=40, b=40),188 xaxis=dict(side="top", showgrid=False),189 yaxis=dict(autorange="reversed", showgrid=False, categoryorder="array", categoryarray=y_labels),190 showlegend=False)191 192 st.plotly_chart(fig, use_container_width=True)193 194 unique_companies = DF['Company'].nunique()195 total_combos = len(DF)196 st.caption(f"고유 기업 수: {unique_companies:,} | 총 분류 조합 수: {total_combos:,} | 절대값 지표: USD 기준")197 198 199 