Files
shawnkim1997 d337c63976 refactor: modular architecture v3.0 + SEC filing viewer fix + README
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>
2026-03-19 22:11:07 +00:00

210 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 89: Excellent** · 47: Average · 03: 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.812.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.")