mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-12 18:18:03 +00:00
210 lines
12 KiB
Python
210 lines
12 KiB
Python
"""Tab 1 — Financial Health (Tables & Charts): Sankey, Radar, F-Score,
|
||||
|
|
Altman Z, red flags, sector metrics, YoY changes, quarterly momentum/ratios."""
|
|||
|
|
|
|||
|
|
import pandas as pd
|
|||
|
|
import streamlit as st
|
|||
|
|
from data.sec_downloader import get_10k_sections
|
|||
|
|
from data.ratios import get_dupont_altman_redflags_yoy, get_quarterly_momentum, get_quarterly_ratio_changes
|
|||
|
|
from data.scores import (
|
|||
|
|
get_income_statement_sankey_data, get_radar_metrics_normalized, _build_radar_figure,
|
|||
|
|
get_piotroski_fscore, get_sector_specific_metrics,
|
|||
|
|
)
|
|||
|
|
from data.scores_ai import sankey_data_from_ai, piotroski_from_ai, radar_metrics_from_ai
|
|||
|
|
from ai.gemini_sec import get_sec_financials_llm
|
|||
|
|
from utils.charts import (
|
|||
|
|
_build_sankey_figure, _build_radar_figure_from_metrics,
|
|||
|
|
_build_radar_from_manual, _apply_dark_theme,
|
|||
|
|
)
|
|||
|
|
try:
|
|||
|
|
from yahooquery import Ticker as YQTicker
|
|||
|
|
except ImportError:
|
|||
|
|
YQTicker = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _style_change_column(df: pd.DataFrame):
|
|||
|
|
"""Green for improvement (+), red for decline (-) in Change column."""
|
|||
|
|
change_col = "Change (%)" if "Change (%)" in df.columns else "Change"
|
|||
|
|
if change_col not in df.columns or df.empty:
|
|||
|
|
return df.style
|
|||
|
|
def _cell_style(v):
|
|||
|
|
if v is None or (isinstance(v, float) and pd.isna(v)):
|
|||
|
|
return ""
|
|||
|
|
s = str(v).strip()
|
|||
|
|
if s == "—":
|
|||
|
|
return ""
|
|||
|
|
if s.startswith("+") or "↑" in s:
|
|||
|
|
return "background-color: #d4edda; color: #155724"
|
|||
|
|
if s.startswith("-") or "↓" in s:
|
|||
|
|
return "background-color: #f8d7da; color: #721c24"
|
|||
|
|
return ""
|
|||
|
|
return df.style.apply(lambda col: [_cell_style(v) for v in col], subset=[change_col])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_tab1_quantitative(ticker, quant_ticker, market, sector, industry, google_api_key, sec_email):
|
|||
|
|
"""Render the Financial Health section of Tab 1."""
|
|||
|
|
ai_data = {}
|
|||
|
|
if market and "US" in market and google_api_key and sec_email:
|
|||
|
|
with st.spinner("SEC 10-K 원본에서 재무제표 데이터를 해독하여 그래프를 생성 중입니다... (약 30~60초 소요)"):
|
|||
|
|
sections, _ = get_10k_sections(ticker, sec_email)
|
|||
|
|
item8 = (sections or {}).get("item8") or ""
|
|||
|
|
if item8.strip():
|
|||
|
|
ai_data = get_sec_financials_llm(google_api_key, item8, ticker)
|
|||
|
|
q = get_dupont_altman_redflags_yoy(quant_ticker)
|
|||
|
|
dupont_df = (q or {}).get("dupont") if q else None
|
|||
|
|
if q or ai_data:
|
|||
|
|
st.markdown("---")
|
|||
|
|
st.markdown("#### 📊 Financial Health (Tables & Charts)")
|
|||
|
|
c1, c2 = st.columns(2)
|
|||
|
|
with c1:
|
|||
|
|
if ai_data and ai_data.get("current_yr"):
|
|||
|
|
sankey_data = sankey_data_from_ai(ai_data)
|
|||
|
|
else:
|
|||
|
|
sankey_data = get_income_statement_sankey_data(quant_ticker)
|
|||
|
|
if sankey_data.get("revenue", 0) > 0:
|
|||
|
|
fig_sankey = _build_sankey_figure(sankey_data)
|
|||
|
|
if fig_sankey is not None:
|
|||
|
|
_apply_dark_theme(fig_sankey)
|
|||
|
|
st.plotly_chart(fig_sankey, use_container_width=True)
|
|||
|
|
else:
|
|||
|
|
st.caption("Income Statement flow: data not available.")
|
|||
|
|
with c2:
|
|||
|
|
if ai_data and ai_data.get("current_yr"):
|
|||
|
|
radar_metrics = radar_metrics_from_ai(ai_data)
|
|||
|
|
fig_radar = _build_radar_figure_from_metrics(radar_metrics) if radar_metrics else None
|
|||
|
|
else:
|
|||
|
|
fig_radar = _build_radar_figure(quant_ticker)
|
|||
|
|
if fig_radar is not None:
|
|||
|
|
_apply_dark_theme(fig_radar)
|
|||
|
|
st.plotly_chart(fig_radar, use_container_width=True)
|
|||
|
|
else:
|
|||
|
|
st.caption("Financial radar: need 2+ years of data.")
|
|||
|
|
with st.expander("Manual Data Entry (Radar Chart Fallback)", expanded=False):
|
|||
|
|
st.caption("Enter 5 key ratios to plot a custom radar. ROE %, Current Ratio, Asset Turnover, Equity Mult., Revenue YoY %.")
|
|||
|
|
roe_man = st.number_input("ROE %", value=15.0, min_value=-50.0, max_value=100.0, step=1.0, key="radar_roe")
|
|||
|
|
cr_man = st.number_input("Current Ratio", value=1.5, min_value=0.0, max_value=10.0, step=0.1, key="radar_cr")
|
|||
|
|
at_man = st.number_input("Asset Turnover", value=0.8, min_value=0.0, max_value=5.0, step=0.1, key="radar_at")
|
|||
|
|
em_man = st.number_input("Equity Mult.", value=2.0, min_value=0.5, max_value=10.0, step=0.1, key="radar_em")
|
|||
|
|
yoy_man = st.number_input("Revenue YoY %", value=10.0, min_value=-50.0, max_value=200.0, step=1.0, key="radar_yoy")
|
|||
|
|
if st.button("Plot Radar", key="radar_plot_btn"):
|
|||
|
|
fig_man = _build_radar_from_manual(roe_man, cr_man, at_man, em_man, yoy_man)
|
|||
|
|
if fig_man is not None:
|
|||
|
|
st.session_state["radar_manual_fig"] = fig_man
|
|||
|
|
if st.session_state.get("radar_manual_fig") is not None:
|
|||
|
|
_apply_dark_theme(st.session_state["radar_manual_fig"])
|
|||
|
|
st.plotly_chart(st.session_state["radar_manual_fig"], use_container_width=True)
|
|||
|
|
with st.expander("Debug: Raw YahooQuery Data", expanded=False):
|
|||
|
|
if YQTicker and quant_ticker:
|
|||
|
|
try:
|
|||
|
|
yq_ticker = YQTicker(quant_ticker.upper())
|
|||
|
|
inc_raw = yq_ticker.income_statement(trailing=False)
|
|||
|
|
bal_raw = yq_ticker.balance_sheet(trailing=False)
|
|||
|
|
if inc_raw is not None and not inc_raw.empty:
|
|||
|
|
st.caption("Income statement (last 2 periods) — check column names for mapping.")
|
|||
|
|
st.dataframe(inc_raw.tail(2), use_container_width=True, hide_index=True)
|
|||
|
|
else:
|
|||
|
|
st.caption("Income statement: no data.")
|
|||
|
|
if bal_raw is not None and not bal_raw.empty:
|
|||
|
|
st.caption("Balance sheet (last 2 periods) — check column names for mapping.")
|
|||
|
|
st.dataframe(bal_raw.tail(2), use_container_width=True, hide_index=True)
|
|||
|
|
else:
|
|||
|
|
st.caption("Balance sheet: no data.")
|
|||
|
|
except Exception as e:
|
|||
|
|
st.error(f"YahooQuery debug failed: {e}")
|
|||
|
|
else:
|
|||
|
|
st.caption("YahooQuery not available or no ticker selected.")
|
|||
|
|
if ai_data and ai_data.get("current_yr"):
|
|||
|
|
piot = piotroski_from_ai(ai_data)
|
|||
|
|
else:
|
|||
|
|
piot = get_piotroski_fscore(quant_ticker)
|
|||
|
|
st.markdown("**Piotroski F-Score (9-point checklist)**")
|
|||
|
|
score = piot.get("score", 0)
|
|||
|
|
legend = "**Score 8–9: Excellent** · 4–7: Average · 0–3: High Risk"
|
|||
|
|
st.metric("F-Score", f"{score} / 9", legend)
|
|||
|
|
if ai_data and ai_data.get("current_yr"):
|
|||
|
|
st.caption("*(from SEC 10-K Item 8)*")
|
|||
|
|
elif piot.get("used_ttm"):
|
|||
|
|
st.caption("*(Estimated via TTM Data)*")
|
|||
|
|
st.caption("✅ = Good (passes criterion). ❌ = Fails criterion.")
|
|||
|
|
criteria = piot.get("criteria", [])
|
|||
|
|
if criteria:
|
|||
|
|
cols = st.columns(3)
|
|||
|
|
for i, (label, passed) in enumerate(criteria):
|
|||
|
|
with cols[i % 3]:
|
|||
|
|
st.caption(("✅ " if passed else "❌ ") + label)
|
|||
|
|
az = (q or {}).get("altman_z")
|
|||
|
|
if az is not None:
|
|||
|
|
st.caption(f"**Altman Z-Score:** {az} (Safe > 2.99 · Grey 1.81–2.99 · Distress < 1.81)")
|
|||
|
|
red_flags = (q or {}).get("red_flags") or []
|
|||
|
|
if red_flags:
|
|||
|
|
for rf in red_flags:
|
|||
|
|
val = rf.get("value")
|
|||
|
|
val_str = "N/A" if (val is None or (isinstance(val, float) and (pd.isna(val) or val != val))) else val
|
|||
|
|
st.warning(f"**{rf.get('metric')}:** {val_str} (threshold: {rf.get('threshold')})")
|
|||
|
|
elif dupont_df is not None and not dupont_df.empty:
|
|||
|
|
st.success("No red flags (Current Ratio ≥ 1.0, Interest Coverage ≥ 1.5).")
|
|||
|
|
sector_metrics = get_sector_specific_metrics(quant_ticker, sector) if quant_ticker else {}
|
|||
|
|
if sector_metrics:
|
|||
|
|
st.markdown("**Sector-specific metrics**")
|
|||
|
|
cols = st.columns(min(len(sector_metrics), 4))
|
|||
|
|
for i, (k, v) in enumerate(sector_metrics.items()):
|
|||
|
|
with cols[i % len(cols)]:
|
|||
|
|
disp = f"{v}" if v is not None else "N/A"
|
|||
|
|
st.metric(k, disp, None)
|
|||
|
|
yoy_list = (q or {}).get("yoy") or []
|
|||
|
|
if yoy_list:
|
|||
|
|
st.markdown("**YoY ratio changes**")
|
|||
|
|
rows_yoy = []
|
|||
|
|
for item in yoy_list:
|
|||
|
|
cur = item.get("Latest")
|
|||
|
|
if cur is not None and isinstance(cur, (int, float)):
|
|||
|
|
cur = round(cur, 2)
|
|||
|
|
chg_pp = item.get("YoY (pp)")
|
|||
|
|
chg_pct = item.get("YoY %")
|
|||
|
|
if chg_pp is not None:
|
|||
|
|
chg_str = f"{chg_pp:+.1f}%"
|
|||
|
|
elif chg_pct is not None:
|
|||
|
|
chg_str = f"{chg_pct:+.1f}%"
|
|||
|
|
else:
|
|||
|
|
chg_str = "—"
|
|||
|
|
status = "↑" if (chg_pp is not None and chg_pp > 0) or (chg_pct is not None and chg_pct > 0) else ("↓" if (chg_pp is not None and chg_pp < 0) or (chg_pct is not None and chg_pct < 0) else "—")
|
|||
|
|
cur_disp = f"{cur:.2f}" if isinstance(cur, (int, float)) else ("—" if cur is None else str(cur))
|
|||
|
|
rows_yoy.append({"Metric": item.get("Ratio"), "Current Value": cur_disp, "Change (%)": chg_str, "Status": status})
|
|||
|
|
if rows_yoy:
|
|||
|
|
df_yoy = pd.DataFrame(rows_yoy)
|
|||
|
|
st.dataframe(_style_change_column(df_yoy), use_container_width=True, hide_index=True)
|
|||
|
|
st.markdown("**Quarter ratio changes**")
|
|||
|
|
qmom = get_quarterly_momentum(quant_ticker)
|
|||
|
|
qoq_rows = get_quarterly_ratio_changes(quant_ticker)
|
|||
|
|
qoq_r, qoq_n = qmom.get("qoq_revenue_pct"), qmom.get("qoq_ni_pct")
|
|||
|
|
build = []
|
|||
|
|
if qoq_r is not None:
|
|||
|
|
build.append({"Metric": "Revenue", "Current Value": "—", "Change (%)": f"{qoq_r:+.1f}%", "Status": "↑" if qoq_r > 0 else "↓"})
|
|||
|
|
if qoq_n is not None:
|
|||
|
|
build.append({"Metric": "Net Income", "Current Value": "—", "Change (%)": f"{qoq_n:+.1f}%", "Status": "↑" if qoq_n > 0 else "↓"})
|
|||
|
|
for r in qoq_rows:
|
|||
|
|
r_copy = dict(r)
|
|||
|
|
if "Current Value" in r_copy:
|
|||
|
|
v = r_copy["Current Value"]
|
|||
|
|
if isinstance(v, (int, float)):
|
|||
|
|
r_copy["Current Value"] = f"{round(v, 2):.2f}"
|
|||
|
|
elif v is None:
|
|||
|
|
r_copy["Current Value"] = "—"
|
|||
|
|
else:
|
|||
|
|
r_copy["Current Value"] = str(v)
|
|||
|
|
if "Change" in r_copy and "Change (%)" not in r_copy:
|
|||
|
|
r_copy["Change (%)"] = r_copy.pop("Change", "—")
|
|||
|
|
if "Trend" in r_copy:
|
|||
|
|
r_copy["Status"] = r_copy.pop("Trend", "—")
|
|||
|
|
build.append(r_copy)
|
|||
|
|
if build:
|
|||
|
|
df_q = pd.DataFrame(build)
|
|||
|
|
if "Change" in df_q.columns and "Change (%)" not in df_q.columns:
|
|||
|
|
df_q = df_q.rename(columns={"Change": "Change (%)"})
|
|||
|
|
if "Trend" in df_q.columns:
|
|||
|
|
df_q = df_q.rename(columns={"Trend": "Status"})
|
|||
|
|
st.dataframe(_style_change_column(df_q), use_container_width=True, hide_index=True)
|
|||
|
|
elif not qmom.get("df") or qmom["df"].empty:
|
|||
|
|
st.caption("Quarterly data not available for this ticker.")
|
|||
|
|
else:
|
|||
|
|
st.info("Quantitative data not available for this ticker.")
|