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

174 lines
8.1 KiB
Python

from typing import Optional
import pandas as pd
import streamlit as st
from utils.formatting import _safe_float
from config.constants import INCOME_ROW_MAP, BALANCE_ROW_MAP, CASHFLOW_ROW_MAP
try:
import yfinance as yf
except ImportError:
yf = None
try:
from yahooquery import Ticker as YQTicker
except ImportError:
YQTicker = None
def _yq_df_to_our_shape(df: pd.DataFrame, row_map: list, date_col: str = "asOfDate") -> Optional[pd.DataFrame]:
"""Convert yahooquery DataFrame (rows=periods, columns=line items) to our shape: index=line names, columns=dates."""
if df is None or df.empty or date_col not in df.columns:
return None
df = df.dropna(subset=[date_col]).sort_values(date_col, ascending=False).head(5)
if df.empty:
return None
dates = df[date_col].astype(str).str[:10].tolist()
data = {}
for our_name, yq_col in row_map:
cols = (yq_col,) if isinstance(yq_col, str) else yq_col
val_col = next((c for c in cols if c in df.columns), None)
if val_col is None:
data[our_name] = [None] * len(dates)
continue
data[our_name] = [_safe_float(v) for v in df[val_col].tolist()]
out = pd.DataFrame(data, index=dates).T
out.columns = dates
return out
def _share_issued_from_yq_balance(df_bal: pd.DataFrame) -> Optional[pd.Series]:
"""Try OrdinarySharesNumber then ShareIssued for shares outstanding in yahooquery balance."""
if df_bal is None or df_bal.empty:
return None
for col in ("OrdinarySharesNumber", "ShareIssued"):
if col in df_bal.columns and "asOfDate" in df_bal.columns:
s = df_bal.set_index("asOfDate")[col].sort_index(ascending=False)
s.index = s.index.astype(str).str[:10]
return s.reindex(s.index) # keep as series with date index
return None
@st.cache_data(ttl=300)
def _get_annual_financials_balance_cashflow_yahooquery(ticker: str) -> tuple:
"""Fetch income, balance, cash flow from yahooquery. Return (fin_df, bal_df, cf_df) with index=line items, columns=dates. TTM fallback if annual insufficient."""
if not YQTicker or not ticker:
return (None, None, None)
try:
yq = YQTicker(ticker.upper())
inc_a = yq.income_statement(frequency="a", trailing=False)
bal_a = yq.balance_sheet(frequency="a", trailing=False)
cf_a = yq.cash_flow(frequency="a", trailing=False)
if inc_a is None or inc_a.empty or bal_a is None or bal_a.empty:
inc_q = yq.income_statement(frequency="q", trailing=False)
bal_q = yq.balance_sheet(frequency="q", trailing=False)
cf_q = yq.cash_flow(frequency="q", trailing=False)
# Build TTM: need at least 2 periods for Piotroski/Radar; use last 4Q and previous 4Q when 8+ quarters
if inc_q is not None and not inc_q.empty and len(inc_q) >= 4:
ttm0 = inc_q.head(4).sum(numeric_only=True)
row0 = ttm0.to_dict() if hasattr(ttm0, "to_dict") else dict(ttm0)
row0["asOfDate"] = inc_q["asOfDate"].iloc[0] if "asOfDate" in inc_q.columns else "TTM0"
rows_inc = [row0]
if len(inc_q) >= 8:
ttm1 = inc_q.iloc[4:8].sum(numeric_only=True)
row1 = ttm1.to_dict() if hasattr(ttm1, "to_dict") else dict(ttm1)
row1["asOfDate"] = inc_q["asOfDate"].iloc[4] if "asOfDate" in inc_q.columns else "TTM1"
rows_inc.append(row1)
inc_a = pd.DataFrame(rows_inc)
if bal_q is not None and not bal_q.empty:
bal_a = bal_q.head(2) if (bal_a is None or bal_a.empty) else bal_a
if cf_q is not None and not cf_q.empty and len(cf_q) >= 4 and (cf_a is None or cf_a.empty):
ttm0_cf = cf_q.head(4).sum(numeric_only=True)
row0_cf = ttm0_cf.to_dict() if hasattr(ttm0_cf, "to_dict") else dict(ttm0_cf)
row0_cf["asOfDate"] = cf_q["asOfDate"].iloc[0] if "asOfDate" in cf_q.columns else "TTM0"
rows_cf = [row0_cf]
if len(cf_q) >= 8:
ttm1_cf = cf_q.iloc[4:8].sum(numeric_only=True)
row1_cf = ttm1_cf.to_dict() if hasattr(ttm1_cf, "to_dict") else dict(ttm1_cf)
row1_cf["asOfDate"] = cf_q["asOfDate"].iloc[4] if "asOfDate" in cf_q.columns else "TTM1"
rows_cf.append(row1_cf)
cf_a = pd.DataFrame(rows_cf)
fin_df = _yq_df_to_our_shape(inc_a, INCOME_ROW_MAP)
bal_df = _yq_df_to_our_shape(bal_a, BALANCE_ROW_MAP)
if bal_df is not None and "Share Issued" not in bal_df.index and bal_a is not None and not bal_a.empty:
for sh_col in ("OrdinarySharesNumber", "ShareIssued"):
if sh_col in bal_a.columns:
row = {"Share Issued": [_safe_float(bal_a[sh_col].iloc[0])]}
if bal_df is not None and not bal_df.empty:
d = str(bal_a["asOfDate"].iloc[0])[:10] if "asOfDate" in bal_a.columns else bal_df.columns[0]
extra = pd.DataFrame(row, index=[d]).T
extra.columns = [d]
bal_df = pd.concat([bal_df, extra], axis=0)
break
cf_df = _yq_df_to_our_shape(cf_a, CASHFLOW_ROW_MAP)
return (fin_df, bal_df, cf_df)
except Exception:
return (None, None, None)
# ---------- Raw statements & FCF = OCF - CapEx ----------
def _get_row_series(df: pd.DataFrame, *names: str) -> Optional[pd.Series]:
if df is None or df.empty:
return None
for name in names:
try:
if name in df.index:
return df.loc[name].copy()
except (KeyError, TypeError):
continue
return None
def _fin_or_bal_empty(df) -> bool:
"""True if DataFrame is missing, empty, or has no columns (e.g. yfinance returned empty)."""
return df is None or df.empty or (hasattr(df, "columns") and len(df.columns) == 0)
@st.cache_data(ttl=300)
def _get_annual_financials_balance_cashflow(ticker: str) -> tuple:
"""Return (fin_df, bal_df, cf_df). Uses yahooquery first; if missing/fail, falls back to yfinance with TTM when needed."""
if not ticker:
return (None, None, None)
fin_df, bal_df, cf_df = _get_annual_financials_balance_cashflow_yahooquery(ticker)
if fin_df is not None and not fin_df.empty and bal_df is not None and not bal_df.empty:
return (fin_df, bal_df, cf_df)
if not yf:
return (None, None, None)
try:
t = yf.Ticker(ticker.upper())
fin = getattr(t, "financials", None)
bal = getattr(t, "balance_sheet", None)
cf = getattr(t, "cashflow", None)
if _fin_or_bal_empty(fin):
qf = getattr(t, "quarterly_financials", None)
if qf is not None and not qf.empty:
n = len(qf.columns)
if n >= 8:
c0 = qf.iloc[:, :4].sum(axis=1)
c1 = qf.iloc[:, 4:8].sum(axis=1)
fin = pd.concat([c0, c1], axis=1)
fin.columns = ["TTM0", "TTM1"]
elif n >= 5:
c0 = qf.iloc[:, :4].sum(axis=1)
c1 = qf.iloc[:, 4:n].sum(axis=1)
fin = pd.concat([c0, c1], axis=1)
fin.columns = ["TTM0", "TTM1"]
else:
fin = qf.iloc[:, : min(4, n)].sum(axis=1).to_frame("TTM0")
if _fin_or_bal_empty(bal):
qb = getattr(t, "quarterly_balance_sheet", None)
if qb is not None and not qb.empty:
n = len(qb.columns)
bal = qb.iloc[:, : min(2, n)].copy()
if bal.shape[1] == 1:
bal.columns = ["B0"]
else:
bal.columns = ["B0", "B1"]
if _fin_or_bal_empty(cf):
qc = getattr(t, "quarterly_cashflow", None)
if qc is not None and not qc.empty:
n = len(qc.columns)
cf = qc.iloc[:, : min(4, n)].sum(axis=1).to_frame("TTM0")
return (fin, bal, cf)
except Exception:
return (None, None, None)