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>
This commit is contained in:
shawnkim1997
2026-03-19 22:11:07 +00:00
co-authored by Claude Sonnet 4.6
parent 7ce5661569
commit d337c63976
41 changed files with 4409 additions and 2942 deletions
View File
+140
View File
@@ -0,0 +1,140 @@
"""
Plotly chart builders: Sankey, Radar, dark theme.
"""
try:
import plotly.graph_objects as go
except ImportError:
go = None
def _build_sankey_figure(data: dict) -> "go.Figure":
"""Sankey: Revenue -> COGS + Gross Profit; Gross Profit -> OpEx + OpInc; OpInc -> Tax/Interest/Other + Net Income."""
if go is None:
return None
rev, cogs, gp, opex, opinc, tax_other, ni = (
data["revenue"], data["cogs"], data["gross_profit"], data["opex"],
data["operating_income"], data["tax_interest_other"], data["net_income"],
)
if rev <= 0:
return None
# Format labels with dollar values
def _fmt(label, val):
if abs(val) >= 1e9:
return f"{label}<br>${val/1e9:.1f}B"
if abs(val) >= 1e6:
return f"{label}<br>${val/1e6:.0f}M"
return label
nodes = [
_fmt("Revenue", rev), _fmt("Cost of Revenue", cogs), _fmt("Gross Profit", gp),
_fmt("Operating Exp.", opex), _fmt("Operating Inc.", opinc),
_fmt("Tax/Int./Other", tax_other), _fmt("Net Income", ni),
]
node_colors = [
"#3B82F6", # Revenue — blue
"#F87171", # COGS — red
"#34D399", # Gross Profit — green
"#FB923C", # OpEx — orange
"#60A5FA", # Operating Income — light blue
"#9CA3AF", # Tax/Interest — grey
"#10B981", # Net Income — bright green
]
link_colors = [
"rgba(248,113,113,0.3)", # Rev -> COGS (red flow)
"rgba(52,211,153,0.3)", # Rev -> GP (green flow)
"rgba(251,146,60,0.3)", # GP -> OpEx (orange flow)
"rgba(96,165,250,0.3)", # GP -> OpInc (blue flow)
"rgba(156,163,175,0.25)", # OpInc -> Tax (grey flow)
"rgba(16,185,129,0.35)", # OpInc -> NI (green flow)
]
source = [0, 0, 2, 2, 4, 4]
target = [1, 2, 3, 4, 5, 6]
value = [max(0, float(v)) for v in [cogs, gp, opex, opinc, tax_other, ni]]
fig = go.Figure(data=[go.Sankey(
node=dict(label=nodes, color=node_colors, pad=20, thickness=24,
line=dict(color="rgba(255,255,255,0.1)", width=1)),
link=dict(source=source, target=target, value=value, color=link_colors),
)])
fig.update_layout(
title=dict(text="Income Statement Flow", font=dict(size=14, color="#F3F4F6", family="Inter")),
height=420, margin=dict(t=45, b=15, l=10, r=10),
font=dict(size=12, color="#D1D5DB", family="Inter"),
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
)
return fig
def _build_radar_common(theta_list, r_list, title_text="Financial Health Radar") -> "go.Figure":
"""Shared radar chart builder with Soft Navy theme."""
if go is None:
return None
theta = theta_list + [theta_list[0]]
r = r_list + [r_list[0]]
fig = go.Figure()
# Add a "benchmark 50" ring for reference
fig.add_trace(go.Scatterpolar(
r=[50] * (len(theta_list) + 1), theta=theta,
fill="toself", fillcolor="rgba(255,255,255,0.02)",
line=dict(color="rgba(255,255,255,0.1)", width=1, dash="dot"),
name="Avg (50)", hoverinfo="skip",
))
fig.add_trace(go.Scatterpolar(
r=r, theta=theta, fill="toself",
fillcolor="rgba(59, 130, 246, 0.2)",
line=dict(color="#60A5FA", width=2.5),
marker=dict(size=6, color="#60A5FA", symbol="circle"),
name="Score",
))
fig.update_layout(
polar=dict(
bgcolor="rgba(0,0,0,0)",
radialaxis=dict(visible=True, range=[0, 100], tickvals=[20, 40, 60, 80],
tickfont=dict(size=9, color="#4B5563", family="JetBrains Mono"),
gridcolor="rgba(255,255,255,0.06)", linecolor="rgba(255,255,255,0.06)"),
angularaxis=dict(tickfont=dict(size=11, color="#D1D5DB", family="Inter"),
gridcolor="rgba(255,255,255,0.06)", linecolor="rgba(255,255,255,0.08)"),
),
title=dict(text=title_text, font=dict(size=14, color="#F3F4F6", family="Inter")),
height=420, showlegend=False,
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
margin=dict(t=45, b=25, l=60, r=60),
)
return fig
def _build_radar_figure_from_metrics(metrics: dict) -> "go.Figure":
"""Build radar chart from precomputed metrics dict."""
if not metrics or not metrics.get("r"):
return None
return _build_radar_common(metrics["theta"], metrics["r"], "Financial Health Radar (10-K Item 8)")
def _radar_norm(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct):
"""Normalize 5 raw metrics to 0-100 for radar (same logic as get_radar_metrics_normalized)."""
def n_roe(x): return min(100, max(0, (x + 10) / 40 * 100)) if x is not None else 50
def n_cr(x): return min(100, max(0, x / 3 * 100)) if x is not None else 50
def n_at(x): return min(100, max(0, x * 50)) if x is not None else 50
def n_em(x): return min(100, max(0, (x - 0.5) / 2.5 * 100)) if x is not None else 50
def n_yoy(x): return min(100, max(0, (x + 20) / 50 * 100)) if x is not None else 50
return [n_roe(roe_pct), n_cr(current_ratio), n_at(asset_turnover), n_em(equity_mult), n_yoy(rev_yoy_pct)]
def _build_radar_from_manual(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct) -> "go.Figure":
"""Build radar chart from 5 manually entered ratios (fallback)."""
theta = ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"]
r = _radar_norm(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct)
return _build_radar_common(theta, r, "Financial Health Radar (Manual)")
def _apply_dark_theme(fig):
"""Apply Soft Navy theme to Plotly figures."""
fig.update_layout(
paper_bgcolor='rgba(0,0,0,0)',
plot_bgcolor='rgba(255,255,255,0.02)',
font=dict(color='#D1D5DB', family='Inter, JetBrains Mono, sans-serif', size=12),
xaxis=dict(gridcolor='rgba(255,255,255,0.05)', zerolinecolor='rgba(255,255,255,0.08)', tickfont=dict(family='JetBrains Mono', size=11)),
yaxis=dict(gridcolor='rgba(255,255,255,0.05)', zerolinecolor='rgba(255,255,255,0.08)', tickfont=dict(family='JetBrains Mono', size=11)),
legend=dict(bgcolor='rgba(0,0,0,0)', bordercolor='rgba(255,255,255,0.06)', font=dict(size=11)),
title_font=dict(color='#F3F4F6', size=14),
margin=dict(l=40, r=20, t=40, b=40),
)
return fig
+75
View File
@@ -0,0 +1,75 @@
"""
DCF valuation models and Damodaran WACC mapping.
"""
from config.constants import DAMODARAN_WACC
def dcf_intrinsic_value(fcf: float, wacc: float, terminal_growth: float, fcf_growth: float, years: int = 5) -> float:
"""5-year DCF: project FCF with fcf_growth, then terminal value; discount at WACC. Returns enterprise value. Robust: avoids div by zero."""
if fcf is None or fcf <= 0:
return 0.0
if wacc <= terminal_growth or wacc <= 0:
return 0.0
pv = 0.0
fcft = float(fcf)
for t in range(1, years + 1):
pv += fcft / ((1 + wacc) ** t)
fcft *= (1 + fcf_growth)
terminal_fcf = fcft
tv = terminal_fcf * (1 + terminal_growth) / (wacc - terminal_growth)
pv += tv / ((1 + wacc) ** years)
return pv
def dcf_10y_2stage(fcf: float, wacc: float, term_growth: float, fcf_growth: float) -> float:
"""10-Year 2-Stage DCF. Stage 1 (Y1-5): FCF grows at fcf_growth. Stage 2 (Y6-10): growth linearly fades from fcf_growth to term_growth by Y10. TV at Y10; discount all to PV."""
if fcf is None or fcf <= 0:
return 0.0
if wacc <= term_growth or wacc <= 0:
return 0.0
pv = 0.0
fcft = float(fcf)
for t in range(1, 6):
pv += fcft / ((1 + wacc) ** t)
fcft *= (1 + fcf_growth)
for t in range(6, 11):
fade = (t - 6) / 4.0
g_t = fcf_growth + fade * (term_growth - fcf_growth)
fcft *= (1 + g_t)
pv += fcft / ((1 + wacc) ** t)
tv = fcft * (1 + term_growth) / (wacc - term_growth)
pv += tv / ((1 + wacc) ** 10)
return pv
def excel_style_dcf(fcf_base: float, wacc: float, term_growth: float, fcf_growth: float, total_debt: float, cash: float, shares: float) -> dict:
"""10Y 2-Stage DCF: EV = PV(FCF Y1-10) + PV(TV); Equity = EV - Debt + Cash; Value per share = Equity / Shares."""
ev = dcf_10y_2stage(fcf_base, wacc, term_growth, fcf_growth)
equity = ev - total_debt + cash
shares_safe = float(shares) if (shares is not None and float(shares) > 0) else None
value_per_share = (equity / shares_safe) if shares_safe else None
return {"ev": ev, "equity_value": equity, "value_per_share": value_per_share, "shares": shares_safe}
def _damodaran_wacc_for_sector(sector: str) -> float:
"""Map yfinance sector string to closest Damodaran WACC. Default 8.0%."""
if not sector:
return 8.0
s = (sector or "").lower()
if "software" in s or "technology" in s or "internet" in s:
return DAMODARAN_WACC.get("Software", 8.5)
if "hardware" in s or "semiconductor" in s:
return DAMODARAN_WACC.get("Hardware", 9.0)
if "retail" in s or "consumer" in s or "cyclical" in s:
return DAMODARAN_WACC.get("Retail", 7.5)
if "financial" in s or "bank" in s or "insurance" in s:
return DAMODARAN_WACC.get("Financials", 8.0)
if "health" in s or "pharma" in s:
return DAMODARAN_WACC.get("Healthcare", 7.2)
if "industrial" in s:
return DAMODARAN_WACC.get("Industrial", 7.8)
if "energy" in s or "oil" in s:
return DAMODARAN_WACC.get("Energy", 8.2)
if "utilities" in s:
return DAMODARAN_WACC.get("Utilities", 6.5)
return 8.0
+36
View File
@@ -0,0 +1,36 @@
"""
Small formatting/type-safety helpers used across multiple modules.
"""
from typing import Optional
import pandas as pd
def _safe_float(x) -> Optional[float]:
if x is None or (isinstance(x, float) and (x != x or pd.isna(x))):
return None
try:
return float(x)
except (TypeError, ValueError):
return None
def _format_shares_display(shares: float) -> str:
"""Format share count for UI, e.g. 15.42B Shares or 1.2B Shares."""
if shares is None or shares <= 0:
return "N/A"
s = float(shares)
if s >= 1e9:
return f"{s / 1e9:.2f}B Shares"
if s >= 1e6:
return f"{s / 1e6:.2f}M Shares"
if s >= 1e3:
return f"{s / 1e3:.2f}K Shares"
return f"{s:.0f} Shares"
def _na(x):
"""Return N/A for None/NaN, else value (for display)."""
if x is None or (isinstance(x, float) and (pd.isna(x) or x != x)):
return "N/A"
return x
+43
View File
@@ -0,0 +1,43 @@
"""
Local preferences: API keys, email, last ticker. Saved to .app_prefs.json.
"""
import json
from pathlib import Path
_PREFS_PATH = Path(__file__).resolve().parent.parent / ".app_prefs.json"
_DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def _load_prefs() -> dict:
"""Load saved API keys and email from local file. Keys: google_api_key, sec_email."""
try:
if _PREFS_PATH.exists():
with open(_PREFS_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_prefs(google_api_key: str, sec_email: str, last_ticker: str = None, last_company_options: list = None, last_company_symbols: list = None) -> None:
"""Save API keys, email, and last selected company to local file."""
try:
data = {}
if _PREFS_PATH.exists():
try:
with open(_PREFS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
pass
data["google_api_key"] = (google_api_key or "").strip()
data["sec_email"] = (sec_email or "").strip()
if last_ticker is not None:
data["last_ticker"] = (last_ticker or "").strip()
if last_company_options is not None:
data["last_company_options"] = list(last_company_options) if last_company_options else []
if last_company_symbols is not None:
data["last_company_symbols"] = list(last_company_symbols) if last_company_symbols else []
with open(_PREFS_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
except Exception:
pass
+37
View File
@@ -0,0 +1,37 @@
"""
Ticker formatting and market inference utilities.
"""
from config.constants import MARKET_OPTIONS
def get_global_ticker(ticker: str, market: str) -> str:
"""Format ticker for Yahoo Finance by market. US: as-is. South Korea: .KS or .KQ. Japan: .T. UK: .L. If ticker already has suffix, return as-is."""
if not (ticker or "").strip():
return (ticker or "").strip()
t = (ticker or "").strip()
if t.upper().endswith((".KS", ".KQ", ".T", ".L")):
return t
m = (market or "").strip()
if "US" in m or not m:
return t
if "Korea" in m or "KOSPI" in m or "KOSDAQ" in m:
return t + ".KS"
if "Japan" in m or "Nikkei" in m:
return t + ".T"
if "UK" in m or "LSE" in m:
return t + ".L"
return t
def infer_market_from_ticker(ticker: str) -> str:
"""Infer market label from ticker suffix (for Deep-Dive routing when no Market selector)."""
if not (ticker or "").strip():
return MARKET_OPTIONS[0]
t = (ticker or "").strip().upper()
if t.endswith(".KS") or t.endswith(".KQ"):
return "South Korea (KOSPI/KOSDAQ)"
if t.endswith(".T"):
return "Japan (Nikkei)"
if t.endswith(".L"):
return "UK (LSE)"
return "US (S&P/Dow/Nasdaq)"
+90
View File
@@ -0,0 +1,90 @@
"""
UI helper functions for rendering analyst consensus, sensitivity tables, etc.
"""
import pandas as pd
import streamlit as st
from utils.dcf import excel_style_dcf
def _render_analyst_consensus(ticker: str):
"""Render analyst consensus rating with visual badge and target prices."""
from data.valuation import get_analyst_consensus
consensus = get_analyst_consensus(ticker)
if not consensus:
st.caption("Analyst consensus data not available.")
return
rec = (consensus.get("recommendationKey") or "N/A").upper()
target_mean = consensus.get("targetMeanPrice")
target_high = consensus.get("targetHighPrice")
target_low = consensus.get("targetLowPrice")
num_analysts = consensus.get("numberOfAnalystOpinions", "N/A")
current = consensus.get("currentPrice")
# Rating badge colors
if rec in ("BUY", "STRONG_BUY", "STRONG BUY"):
badge_bg = "rgba(52, 211, 153, 0.15)"
badge_border = "rgba(52, 211, 153, 0.4)"
badge_color = "#34D399"
elif rec in ("SELL", "STRONG_SELL", "STRONG SELL"):
badge_bg = "rgba(248, 113, 113, 0.15)"
badge_border = "rgba(248, 113, 113, 0.4)"
badge_color = "#F87171"
else:
badge_bg = "rgba(251, 191, 36, 0.15)"
badge_border = "rgba(251, 191, 36, 0.4)"
badge_color = "#FBBF24"
rec_display = rec.replace("_", " ")
upside = ""
if target_mean and current and current > 0:
upside_pct = (target_mean - current) / current * 100
upside_color = "#34D399" if upside_pct > 0 else "#F87171"
upside = f'<span style="color: {upside_color}; font-family: JetBrains Mono, monospace; font-weight: 600; margin-left: 12px;">{upside_pct:+.1f}% implied</span>'
if target_mean:
st.markdown(f"""
<div style="display: flex; align-items: center; gap: 16px; padding: 16px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; margin-bottom: 12px;">
<div>
<div style="color: #6B7280; font-size: 0.65rem; font-weight: 600; letter-spacing: 1px; text-transform: uppercase;">ANALYST RATING</div>
<span style="display: inline-block; padding: 4px 14px; border-radius: 6px; font-weight: 700; font-family: JetBrains Mono, monospace; background: {badge_bg}; border: 1px solid {badge_border}; color: {badge_color}; font-size: 1.1rem; margin-top: 4px;">{rec_display}</span>
{upside}
</div>
<div style="margin-left: auto; display: flex; gap: 24px;">
<div style="text-align: center;">
<div style="color: #6B7280; font-size: 0.6rem; letter-spacing: 1px;">TARGET (MEAN)</div>
<div style="color: #F3F4F6; font-size: 1.1rem; font-family: JetBrains Mono, monospace; font-weight: 700;">${target_mean:,.2f}</div>
</div>
<div style="text-align: center;">
<div style="color: #6B7280; font-size: 0.6rem; letter-spacing: 1px;">HIGH</div>
<div style="color: #34D399; font-size: 0.95rem; font-family: JetBrains Mono, monospace;">${target_high:,.2f}</div>
</div>
<div style="text-align: center;">
<div style="color: #6B7280; font-size: 0.6rem; letter-spacing: 1px;">LOW</div>
<div style="color: #F87171; font-size: 0.95rem; font-family: JetBrains Mono, monospace;">${target_low:,.2f}</div>
</div>
<div style="text-align: center;">
<div style="color: #6B7280; font-size: 0.6rem; letter-spacing: 1px;">ANALYSTS</div>
<div style="color: #D1D5DB; font-size: 0.95rem; font-family: JetBrains Mono, monospace;">{num_analysts}</div>
</div>
</div>
</div>
""", unsafe_allow_html=True)
else:
st.caption("Analyst targets not available.")
def _render_sensitivity_table(fcf: float, total_debt: float, cash: float, shares: float):
"""Render DCF sensitivity table: WACC vs Terminal Growth Rate."""
wacc_range = [0.065, 0.070, 0.075, 0.080, 0.085, 0.090, 0.095, 0.100]
tgr_range = [0.020, 0.025, 0.030, 0.035, 0.040]
rows = []
for tgr in tgr_range:
row = {"TGR": f"{tgr*100:.1f}%"}
for w in wacc_range:
res = excel_style_dcf(fcf, w, tgr, 0.10, total_debt, cash, shares)
val = res.get("value_per_share", 0)
row[f"{w*100:.1f}%"] = f"${val:,.0f}" if val and val > 0 else "N/A"
rows.append(row)
return pd.DataFrame(rows).set_index("TGR")