mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-07-27 18:47: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>
240 lines
15 KiB
Python
240 lines
15 KiB
Python
import pandas as pd
|
|
import streamlit as st
|
|
from config.constants import MARKET_OPTIONS, DAMODARAN_ERP_PCT, DAMODARAN_RF_PCT
|
|
from utils.ticker import get_global_ticker
|
|
from utils.formatting import _format_shares_display
|
|
from utils.dcf import excel_style_dcf, _damodaran_wacc_for_sector
|
|
from utils.charts import _apply_dark_theme
|
|
from utils.ui_helpers import _render_analyst_consensus, _render_sensitivity_table
|
|
from data.fundamentals import get_sector_industry, get_5yr_financial_trend, get_dcf_inputs
|
|
from data.valuation import get_analyst_consensus, get_dcf_smart_defaults, get_fcff_fcfe_valuation
|
|
try:
|
|
import plotly.express as px
|
|
except ImportError:
|
|
px = None
|
|
try:
|
|
import yfinance as yf
|
|
except ImportError:
|
|
yf = None
|
|
|
|
|
|
def render_tab2(ticker):
|
|
market_t2 = st.session_state.get("market") or MARKET_OPTIONS[0]
|
|
quant_ticker_t2 = get_global_ticker(ticker, market_t2) if ticker else ""
|
|
st.subheader("5-Year Financial Trend & DCF Valuation")
|
|
if ticker:
|
|
si_t2 = get_sector_industry(quant_ticker_t2)
|
|
sector_t2 = (si_t2.get("sector") or "").lower()
|
|
is_financial = "financial" in sector_t2 or "bank" in sector_t2 or "insurance" in sector_t2
|
|
else:
|
|
is_financial = False
|
|
df_trend = get_5yr_financial_trend(quant_ticker_t2) if quant_ticker_t2 else pd.DataFrame()
|
|
if not df_trend.empty and len(df_trend) >= 1:
|
|
st.markdown("#### Key metrics (YoY % change)")
|
|
latest = df_trend.iloc[0]
|
|
prev = df_trend.iloc[1] if len(df_trend) >= 2 else None
|
|
def _yoy_pct(cur, prev_val):
|
|
if prev_val is None or cur is None or prev_val == 0:
|
|
return None
|
|
return (cur - prev_val) / abs(prev_val) * 100
|
|
rev_yoy = _yoy_pct(latest.get("Revenue"), prev.get("Revenue") if prev is not None else None)
|
|
ni_yoy = _yoy_pct(latest.get("Net Income"), prev.get("Net Income") if prev is not None else None)
|
|
om_prev = prev.get("Operating Margin %") if prev is not None else None
|
|
om_cur = latest.get("Operating Margin %")
|
|
om_yoy = (om_cur - om_prev) if (om_cur is not None and om_prev is not None) else None
|
|
fcf_yoy = _yoy_pct(latest.get("FCF"), prev.get("FCF") if prev is not None else None)
|
|
m1, m2, m3, m4 = st.columns(4)
|
|
rev_val = latest.get("Revenue")
|
|
m1.metric("Revenue (latest yr)", f"${rev_val/1e9:.2f}B" if rev_val and rev_val >= 1e9 else (f"${rev_val/1e6:.0f}M" if rev_val else "\u2014"), f"{rev_yoy:+.1f}% YoY" if rev_yoy is not None else None)
|
|
ni_val = latest.get("Net Income")
|
|
m2.metric("Net Income", f"${ni_val/1e9:.2f}B" if ni_val and abs(ni_val) >= 1e9 else (f"${ni_val/1e6:.0f}M" if ni_val is not None else "\u2014"), f"{ni_yoy:+.1f}% YoY" if ni_yoy is not None else None)
|
|
om_val = latest.get("Operating Margin %")
|
|
m3.metric("Operating Margin %", f"{om_val:.1f}%" if om_val is not None else "\u2014", f"{om_yoy:+.1f}pp YoY" if om_yoy is not None else None)
|
|
fcf_val = latest.get("FCF")
|
|
m4.metric("FCF", f"${fcf_val/1e9:.2f}B" if fcf_val and abs(fcf_val) >= 1e9 else (f"${fcf_val/1e6:.0f}M" if fcf_val is not None else "\u2014"), f"{fcf_yoy:+.1f}% YoY" if fcf_yoy is not None else None)
|
|
st.caption("FCF = Operating Cash Flow \u2212 Capital Expenditure." + (" For Financials, FCF/EBITDA are less relevant; see ROE/ROA in Tab 1 sector-specific metrics." if is_financial else ""))
|
|
if len(df_trend) >= 2 and px is not None:
|
|
st.markdown("#### 5-year trend: Revenue & FCF")
|
|
df_plot = df_trend.copy()
|
|
df_plot["Revenue_M"] = (df_plot["Revenue"] / 1e6).round(1)
|
|
df_plot["FCF_M"] = (df_plot["FCF"] / 1e6).round(1)
|
|
fig = px.line(df_plot, x="Year", y=["Revenue_M", "FCF_M"], title="Revenue & Free Cash Flow ($M)")
|
|
fig.update_layout(yaxis_title="$M", legend_title="", hovermode="x unified")
|
|
fig.update_traces(line=dict(width=2))
|
|
_apply_dark_theme(fig)
|
|
st.plotly_chart(fig, use_container_width=True)
|
|
elif ticker:
|
|
st.caption("5-year trend not available for this ticker. DCF section below uses latest FCF from yfinance.")
|
|
st.markdown("---")
|
|
st.markdown("#### DCF valuation (Excel-style): inputs & 3-scenario output")
|
|
dcf_inputs = get_dcf_inputs(quant_ticker_t2) if quant_ticker_t2 else {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None}
|
|
fcf_fetched = dcf_inputs.get("fcf")
|
|
total_debt = float(dcf_inputs.get("total_debt") or 0.0)
|
|
cash = float(dcf_inputs.get("cash") or 0.0)
|
|
shares_fetched = dcf_inputs.get("shares")
|
|
# Base FCF
|
|
if fcf_fetched is None or fcf_fetched <= 0:
|
|
fcf = st.number_input("Base FCF (manual \u2014 only if yfinance missing)", value=0.0, min_value=-1e12, step=1e8, format="%.0f", key="dcf_fcf_manual")
|
|
else:
|
|
fcf = float(fcf_fetched)
|
|
st.caption(f"Base FCF (OCF \u2212 CapEx): **${fcf/1e9:.2f}B**" if abs(fcf) >= 1e9 else f"Base FCF (OCF \u2212 CapEx): **${fcf/1e6:.0f}M**")
|
|
# Shares: auto-fetched (fast_info -> info -> balance); manual only as last resort
|
|
if shares_fetched is not None and shares_fetched > 0:
|
|
shares = float(shares_fetched)
|
|
st.caption(f"Shares Outstanding: **{_format_shares_display(shares)}** (real-time, auto-fetched)")
|
|
else:
|
|
shares = st.number_input("Shares Outstanding (manual \u2014 only if all API sources failed)", value=1e9, min_value=1.0, step=1e7, format="%.0f", key="dcf_shares_manual")
|
|
# Total Debt & Cash: manual only when both API sources completely failed
|
|
if total_debt == 0 and cash == 0:
|
|
c1, c2 = st.columns(2)
|
|
with c1:
|
|
total_debt = st.number_input("Total Debt (manual \u2014 only if all sources failed)", value=0.0, min_value=0.0, step=1e8, format="%.0f", key="dcf_debt_manual")
|
|
with c2:
|
|
cash = st.number_input("Cash & Equivalents (manual \u2014 only if all sources failed)", value=0.0, min_value=0.0, step=1e8, format="%.0f", key="dcf_cash_manual")
|
|
else:
|
|
st.caption(f"Total Debt: **${total_debt/1e9:.2f}B**" if total_debt >= 1e9 else f"Total Debt: **${total_debt/1e6:.0f}M**" if total_debt >= 1e6 else f"Total Debt: **${total_debt:,.0f}**")
|
|
st.caption(f"Cash & Equivalents: **${cash/1e9:.2f}B**" if cash >= 1e9 else f"Cash & Equivalents: **${cash/1e6:.0f}M**" if cash >= 1e6 else f"Cash & Equivalents: **${cash:,.0f}**")
|
|
dcf_defaults = get_dcf_smart_defaults(quant_ticker_t2) if quant_ticker_t2 else {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0}
|
|
st.markdown("**Assumptions (sliders)**")
|
|
st.caption("\U0001f4a1 Slider defaults are auto-generated based on the company's Beta (CAPM) and revenue growth estimates.")
|
|
col1, col2, col3 = st.columns(3)
|
|
with col1:
|
|
wacc = st.slider("WACC (Discount Rate) %", 4.0, 20.0, float(dcf_defaults["wacc_pct"]), 0.5, key="dcf_wacc") / 100.0
|
|
with col2:
|
|
term_growth = st.slider("Terminal Growth Rate %", -2.0, 6.0, float(dcf_defaults["term_growth_pct"]), 0.25, key="dcf_term") / 100.0
|
|
with col3:
|
|
base_growth = st.slider("Projected FCF Growth (Stage 1, Y1\u20135) %", -10.0, 30.0, float(dcf_defaults["fcf_growth_pct"]), 0.5, key="dcf_fcf_growth") / 100.0
|
|
bull_growth = base_growth + 0.02
|
|
bear_growth = base_growth - 0.02
|
|
with st.expander("Reference: Analyst & Macro Assumptions", expanded=False):
|
|
left_col, right_col = st.columns(2)
|
|
with left_col:
|
|
st.markdown("**Analyst consensus (yfinance)**")
|
|
analyst = get_analyst_consensus(quant_ticker_t2) if quant_ticker_t2 else {}
|
|
_tmp = analyst.get('targetMeanPrice')
|
|
st.markdown(f"- **Target mean price:** ${_tmp:,.2f}" if _tmp else "- **Target mean price:** N/A")
|
|
st.markdown(f"- **Recommendation:** {analyst.get('recommendationKey', 'N/A')}")
|
|
st.markdown(f"- **Revenue growth est.:** {analyst.get('revenueGrowth', 'N/A')}")
|
|
st.markdown(f"- **Earnings growth est.:** {analyst.get('earningsGrowth', 'N/A')}")
|
|
with right_col:
|
|
st.markdown("**Aswath Damodaran \u2014 macro baseline**")
|
|
sector_name = get_sector_industry(ticker).get("sector", "N/A") if ticker else "N/A"
|
|
damodaran_wacc = _damodaran_wacc_for_sector(sector_name) if ticker else 8.0
|
|
st.markdown(f"- **Sector WACC (ref.):** {damodaran_wacc:.1f}% (closest: {sector_name})")
|
|
st.markdown(f"- **US equity risk premium (ERP):** {DAMODARAN_ERP_PCT}%")
|
|
st.markdown(f"- **10Y risk-free rate:** {DAMODARAN_RF_PCT}%")
|
|
st.markdown("[Data & methodology (Damodaran)](https://pages.stern.nyu.edu/~adamodar/New_Home_Page/datafile/wacc.htm) so users can verify.")
|
|
res_base = excel_style_dcf(fcf, wacc, term_growth, base_growth, total_debt, cash, shares)
|
|
res_bull = excel_style_dcf(fcf, wacc, term_growth, bull_growth, total_debt, cash, shares)
|
|
res_bear = excel_style_dcf(fcf, wacc, term_growth, bear_growth, total_debt, cash, shares)
|
|
price_base = res_base.get("value_per_share") or 0.0
|
|
price_bull = res_bull.get("value_per_share") or 0.0
|
|
price_bear = res_bear.get("value_per_share") or 0.0
|
|
current_price = None
|
|
if quant_ticker_t2 and yf:
|
|
try:
|
|
info = yf.Ticker(quant_ticker_t2.upper()).info or {}
|
|
current_price = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose")
|
|
except Exception:
|
|
pass
|
|
st.markdown("**Intrinsic value vs current price**")
|
|
if current_price is not None and current_price > 0:
|
|
st.metric("Current price", f"${current_price:.2f}", None)
|
|
st.metric("Base case intrinsic value per share", f"${price_base:.2f}" if price_base else "N/A", f"vs current: {(price_base - current_price):.2f}" if (current_price and price_base) else None)
|
|
c1, c2, c3 = st.columns(3)
|
|
c1.metric("Bull (+2% FCF growth)", f"${price_bull:.2f}" if price_bull else "N/A", f"vs Base: +{(price_bull - price_base):.2f}" if (price_bull and price_base) else None)
|
|
c2.metric("Base", f"${price_base:.2f}" if price_base else "N/A", "\u2014")
|
|
c3.metric("Bear (\u22122% FCF growth)", f"${price_bear:.2f}" if price_bear else "N/A", f"vs Base: {(price_bear - price_base):.2f}" if (price_bear and price_base) else None)
|
|
df_dcf = pd.DataFrame({
|
|
"Scenario": ["Bull", "Base", "Bear"],
|
|
"FCF Growth %": [f"{bull_growth*100:.1f}", f"{base_growth*100:.1f}", f"{bear_growth*100:.1f}"],
|
|
"Intrinsic Value ($)": [round(price_bull, 2) if price_bull else "N/A", round(price_base, 2) if price_base else "N/A", round(price_bear, 2) if price_bear else "N/A"],
|
|
})
|
|
st.dataframe(df_dcf, use_container_width=True, hide_index=True)
|
|
|
|
# --- Probability-Weighted Expected Return ---
|
|
st.markdown("**Probability-Weighted Expected Return**")
|
|
bull_prob, base_prob, bear_prob = 0.25, 0.55, 0.20
|
|
pw_value = bull_prob * price_bull + base_prob * price_base + bear_prob * price_bear
|
|
if current_price and current_price > 0 and pw_value > 0:
|
|
pw_return = (pw_value - current_price) / current_price * 100
|
|
st.markdown(f"""
|
|
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; padding: 16px;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<div>
|
|
<div style="color: #6B7280; font-size: 0.7rem; font-weight: 600; letter-spacing: 1px;">PROBABILITY-WEIGHTED VALUE</div>
|
|
<div style="color: #F3F4F6; font-size: 1.4rem; font-family: JetBrains Mono, monospace; font-weight: 700;">${pw_value:,.2f}</div>
|
|
</div>
|
|
<div>
|
|
<div style="color: #6B7280; font-size: 0.7rem; font-weight: 600; letter-spacing: 1px;">EXPECTED RETURN</div>
|
|
<div style="color: {'#34D399' if pw_return > 0 else '#F87171'}; font-size: 1.4rem; font-family: JetBrains Mono, monospace; font-weight: 700;">{pw_return:+.1f}%</div>
|
|
</div>
|
|
<div style="color: #6B7280; font-size: 0.75rem; font-family: JetBrains Mono, monospace;">
|
|
Bull {bull_prob*100:.0f}% x ${price_bull:,.0f} + Base {base_prob*100:.0f}% x ${price_base:,.0f} + Bear {bear_prob*100:.0f}% x ${price_bear:,.0f}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
# --- Analyst Consensus ---
|
|
st.markdown("---")
|
|
st.markdown("#### Analyst Consensus")
|
|
_render_analyst_consensus(quant_ticker_t2)
|
|
|
|
# --- FCFF / FCFE Analysis ---
|
|
st.markdown("---")
|
|
st.markdown("#### FCFF / FCFE Analysis")
|
|
fcff_data = get_fcff_fcfe_valuation(quant_ticker_t2) if quant_ticker_t2 else {}
|
|
if fcff_data:
|
|
c1_ff, c2_ff, c3_ff, c4_ff = st.columns(4)
|
|
with c1_ff:
|
|
fcff_val = fcff_data.get("fcff")
|
|
st.metric("FCFF", f"${fcff_val/1e9:.2f}B" if fcff_val and abs(fcff_val) >= 1e9 else (f"${fcff_val/1e6:.0f}M" if fcff_val else "N/A"))
|
|
with c2_ff:
|
|
fcfe_val = fcff_data.get("fcfe")
|
|
st.metric("FCFE", f"${fcfe_val/1e9:.2f}B" if fcfe_val and abs(fcfe_val) >= 1e9 else (f"${fcfe_val/1e6:.0f}M" if fcfe_val else "N/A"))
|
|
with c3_ff:
|
|
_da_v = fcff_data.get("depreciation")
|
|
st.metric("D&A", f"${_da_v/1e9:.2f}B" if _da_v and abs(_da_v) >= 1e9 else (f"${_da_v/1e6:.0f}M" if _da_v else "N/A"))
|
|
with c4_ff:
|
|
_cx_v = fcff_data.get("capex")
|
|
st.metric("CapEx", f"${_cx_v/1e9:.2f}B" if _cx_v and abs(_cx_v) >= 1e9 else (f"${_cx_v/1e6:.0f}M" if _cx_v else "N/A"))
|
|
|
|
with st.expander("FCFF/FCFE Bridge Detail", expanded=False):
|
|
def _fmt_b(v):
|
|
if v is None: return "N/A"
|
|
return f"${v/1e9:.2f}B" if abs(v) >= 1e9 else f"${v/1e6:.0f}M"
|
|
_ebit = fcff_data.get('ebit') or 0
|
|
_tr_pct = fcff_data.get('tax_rate') or 21
|
|
_da = fcff_data.get('depreciation') or 0
|
|
_cx = fcff_data.get('capex') or 0
|
|
bridge_data = {
|
|
"Component": ["EBIT", "x (1 - Tax Rate)", "= NOPAT", "+ D&A", "- CapEx", "= FCFF", "", "Net Income", "+ D&A", "- CapEx", "= FCFE"],
|
|
"Value": [
|
|
_fmt_b(fcff_data.get('ebit')),
|
|
f"{_tr_pct:.1f}%",
|
|
_fmt_b(_ebit * (1 - _tr_pct/100)) if fcff_data.get('ebit') else "N/A",
|
|
_fmt_b(fcff_data.get('depreciation')),
|
|
_fmt_b(fcff_data.get('capex')),
|
|
_fmt_b(fcff_data.get('fcff')),
|
|
"---",
|
|
_fmt_b(fcff_data.get('net_income')),
|
|
_fmt_b(fcff_data.get('depreciation')),
|
|
_fmt_b(fcff_data.get('capex')),
|
|
_fmt_b(fcff_data.get('fcfe')),
|
|
]
|
|
}
|
|
st.dataframe(pd.DataFrame(bridge_data), use_container_width=True, hide_index=True)
|
|
else:
|
|
st.caption("FCFF/FCFE data not available for this ticker.")
|
|
|
|
# --- DCF Sensitivity Analysis ---
|
|
st.markdown("---")
|
|
st.markdown("#### DCF Sensitivity Analysis")
|
|
st.caption("Intrinsic value per share across WACC and Terminal Growth Rate assumptions")
|
|
if fcf and fcf > 0 and shares and shares > 0:
|
|
sens_df = _render_sensitivity_table(fcf, total_debt, cash, shares)
|
|
st.dataframe(sens_df, use_container_width=True)
|
|
else:
|
|
st.caption("Sensitivity table requires positive FCF data.")
|