aboutsummaryrefslogtreecommitdiff
path: root/components/valuation.py
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-03-28 23:01:14 -0700
committerTyler <tyler@tylerhoang.xyz>2026-03-28 23:01:14 -0700
commit23675b39b8055a8568cdcf71f66482b9d0cf90a9 (patch)
tree14e42cf710b47072e904b1c21d7322352ae1823c /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.py208
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,
+ )