diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-03-28 23:01:14 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-03-28 23:01:14 -0700 |
| commit | 23675b39b8055a8568cdcf71f66482b9d0cf90a9 (patch) | |
| tree | 14e42cf710b47072e904b1c21d7322352ae1823c /components/valuation.py | |
Initial commit — Prism financial analysis dashboard
Streamlit app with market bar, price chart, financial statements,
DCF valuation engine, comparable companies, and news feed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'components/valuation.py')
| -rw-r--r-- | components/valuation.py | 208 |
1 files changed, 208 insertions, 0 deletions
diff --git a/components/valuation.py b/components/valuation.py new file mode 100644 index 0000000..6549d07 --- /dev/null +++ b/components/valuation.py @@ -0,0 +1,208 @@ +"""Valuation panel — key ratios, DCF model, comparable companies.""" +import pandas as pd +import plotly.graph_objects as go +import streamlit as st +from services.data_service import get_company_info, get_free_cash_flow_series +from services.fmp_service import get_key_ratios, get_peers, get_ratios_for_tickers +from services.valuation_service import run_dcf +from utils.formatters import fmt_ratio, fmt_pct, fmt_large, fmt_currency + + +def render_valuation(ticker: str): + tab_ratios, tab_dcf, tab_comps = st.tabs(["Key Ratios", "DCF Model", "Comps"]) + + with tab_ratios: + _render_ratios(ticker) + + with tab_dcf: + _render_dcf(ticker) + + with tab_comps: + _render_comps(ticker) + + +# ── Key Ratios ─────────────────────────────────────────────────────────────── + +def _render_ratios(ticker: str): + ratios = get_key_ratios(ticker) + info = get_company_info(ticker) + + if not ratios and not info: + st.info("Ratio data unavailable. Check your FMP API key.") + return + + # Prefer FMP ratios, fall back to yfinance info + def r(fmp_key, yf_key=None, fmt=fmt_ratio): + val = ratios.get(fmp_key) if ratios else None + if val is None and yf_key and info: + val = info.get(yf_key) + return fmt(val) if val is not None else "—" + + rows = [ + ("Valuation", [ + ("P/E (TTM)", r("peRatioTTM", "trailingPE")), + ("Forward P/E", r("priceEarningsRatioTTM", "forwardPE")), + ("P/S (TTM)", r("priceToSalesRatioTTM", "priceToSalesTrailing12Months")), + ("P/B", r("priceToBookRatioTTM", "priceToBook")), + ("EV/EBITDA", r("enterpriseValueMultipleTTM", "enterpriseToEbitda")), + ("EV/Revenue", r("evToSalesTTM", "enterpriseToRevenue")), + ]), + ("Profitability", [ + ("Gross Margin", r("grossProfitMarginTTM", "grossMargins", fmt_pct)), + ("Operating Margin", r("operatingProfitMarginTTM", "operatingMargins", fmt_pct)), + ("Net Margin", r("netProfitMarginTTM", "profitMargins", fmt_pct)), + ("ROE", r("returnOnEquityTTM", "returnOnEquity", fmt_pct)), + ("ROA", r("returnOnAssetsTTM", "returnOnAssets", fmt_pct)), + ("ROIC", r("returnOnInvestedCapitalTTM", fmt=fmt_pct)), + ]), + ("Leverage & Liquidity", [ + ("Debt/Equity", r("debtEquityRatioTTM", "debtToEquity")), + ("Current Ratio", r("currentRatioTTM", "currentRatio")), + ("Quick Ratio", r("quickRatioTTM", "quickRatio")), + ("Interest Coverage", r("interestCoverageTTM")), + ("Dividend Yield", r("dividendYieldTTM", "dividendYield", fmt_pct)), + ("Payout Ratio", r("payoutRatioTTM", "payoutRatio", fmt_pct)), + ]), + ] + + for section_name, metrics in rows: + st.markdown(f"**{section_name}**") + cols = st.columns(6) + for col, (label, val) in zip(cols, metrics): + col.metric(label, val) + st.write("") + + +# ── DCF Model ──────────────────────────────────────────────────────────────── + +def _render_dcf(ticker: str): + info = get_company_info(ticker) + shares = info.get("sharesOutstanding") or info.get("floatShares") + current_price = info.get("currentPrice") or info.get("regularMarketPrice") + + if not shares: + st.info("Shares outstanding not available — DCF cannot be computed.") + return + + fcf_series = get_free_cash_flow_series(ticker) + if fcf_series.empty: + st.info("Free cash flow data unavailable.") + return + + st.markdown("**Assumptions**") + col1, col2, col3 = st.columns(3) + with col1: + wacc = st.slider("WACC (%)", min_value=5.0, max_value=20.0, value=10.0, step=0.5) / 100 + with col2: + terminal_growth = st.slider("Terminal Growth (%)", min_value=0.5, max_value=5.0, value=2.5, step=0.5) / 100 + with col3: + projection_years = st.slider("Projection Years", min_value=3, max_value=10, value=5, step=1) + + result = run_dcf( + fcf_series=fcf_series, + shares_outstanding=shares, + wacc=wacc, + terminal_growth=terminal_growth, + projection_years=projection_years, + ) + + if not result: + st.warning("Insufficient data to run DCF model.") + return + + iv = result["intrinsic_value_per_share"] + + # ── Summary metrics ────────────────────────────────────────────────────── + m1, m2, m3, m4 = st.columns(4) + m1.metric("Intrinsic Value / Share", fmt_currency(iv)) + if current_price: + upside = (iv - current_price) / current_price + m2.metric("Current Price", fmt_currency(current_price)) + m3.metric("Upside / Downside", f"{upside * 100:+.1f}%", delta=f"{upside * 100:+.1f}%") + m4.metric("FCF Growth Used", f"{result['growth_rate_used'] * 100:.1f}%") + + st.write("") + + # ── Waterfall chart ─────────────────────────────────────────────────────── + years = [f"Year {y}" for y in result["years"]] + discounted = result["discounted_fcfs"] + terminal_pv = result["terminal_value_pv"] + + bar_labels = years + ["Terminal Value"] + bar_values = discounted + [terminal_pv] + bar_colors = ["#4F8EF7"] * len(years) + ["#F7A24F"] + + fig = go.Figure( + go.Bar( + x=bar_labels, + y=[v / 1e9 for v in bar_values], + marker_color=bar_colors, + text=[f"${v / 1e9:.2f}B" for v in bar_values], + textposition="outside", + ) + ) + fig.update_layout( + title="PV of Projected FCFs + Terminal Value (Billions)", + yaxis_title="USD (Billions)", + plot_bgcolor="rgba(0,0,0,0)", + paper_bgcolor="rgba(0,0,0,0)", + margin=dict(l=0, r=0, t=40, b=0), + height=360, + ) + st.plotly_chart(fig, use_container_width=True) + + +# ── Comps Table ────────────────────────────────────────────────────────────── + +def _render_comps(ticker: str): + peers = get_peers(ticker) + if not peers: + st.info("No comparable companies found. Check your FMP API key.") + return + + # Include the subject ticker + all_tickers = [ticker.upper()] + [p for p in peers[:9] if p != ticker.upper()] + + with st.spinner("Loading comps..."): + ratios_list = get_ratios_for_tickers(all_tickers) + + if not ratios_list: + st.info("Could not load ratios for peer companies.") + return + + display_cols = { + "symbol": "Ticker", + "peRatioTTM": "P/E", + "priceToSalesRatioTTM": "P/S", + "priceToBookRatioTTM": "P/B", + "enterpriseValueMultipleTTM": "EV/EBITDA", + "netProfitMarginTTM": "Net Margin", + "returnOnEquityTTM": "ROE", + "debtEquityRatioTTM": "D/E", + } + + df = pd.DataFrame(ratios_list) + available = [c for c in display_cols if c in df.columns] + df = df[available].rename(columns=display_cols) + + # Format numeric columns + pct_cols = {"Net Margin", "ROE"} + for col in df.columns: + if col == "Ticker": + continue + if col in pct_cols: + df[col] = df[col].apply(lambda v: fmt_pct(v) if v is not None else "—") + else: + df[col] = df[col].apply(lambda v: fmt_ratio(v) if v is not None else "—") + + # Highlight subject ticker row + def highlight_subject(row): + if row["Ticker"] == ticker.upper(): + return ["background-color: rgba(79,142,247,0.15)"] * len(row) + return [""] * len(row) + + st.dataframe( + df.style.apply(highlight_subject, axis=1), + use_container_width=True, + hide_index=True, + ) |
