mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-02 13:27:44 +00:00
d337c63976
Architecture (3,909-line monolith → 28 focused modules, all < 300 lines):
- config/: constants.py (company lists, row maps, Damodaran baselines), theme.py (CSS/HTML)
- utils/: prefs, formatting, ticker, dcf, charts, ui_helpers
- data/: sec_parser, sec_fetcher, sec_downloader, financials, fundamentals,
valuation, ratios, scores, scores_ai, market
- ai/: gemini_core, gemini_sec, gemini_insights
- views/: sidebar, tab1_quant, tab1_ai, tab1_filings, tab2_dcf,
tab3_comps, tab4_news, tab5_markets, tab6_crypto, tab7_technical
- app.py: thin orchestrator (~118 lines)
- Strict unidirectional dependency graph (no circular imports)
- All @st.cache_data TTLs and st.session_state keys preserved identically
SEC filing viewer fix:
- Rebuilt EDGAR fetch chain: company_tickers.json → CIK → submissions API
→ filings.recent.primaryDocument[] (replaces deprecated directory.item)
- Filing type selectbox (10-K, 10-Q, 8-K, 20-F, 6-K) connected to backend
- Native HTML rendered via streamlit.components.v1.html() with CSS reset
- Errors surfaced explicitly with st.error()
- DART direct links restored for Korean-listed companies
.gitignore: data/ → data/*.json + data/*.html (preserve Python modules)
README: full rewrite for master's portfolio — 7-tab layout, architecture
diagram, modular structure tree, technical challenges, design rationale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.")
|