From 23675b39b8055a8568cdcf71f66482b9d0cf90a9 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 28 Mar 2026 23:01:14 -0700 Subject: Initial commit — Prism financial analysis dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streamlit app with market bar, price chart, financial statements, DCF valuation engine, comparable companies, and news feed. Co-Authored-By: Claude Sonnet 4.6 --- components/__init__.py | 0 components/financials.py | 82 +++++++++++++++++++ components/market_bar.py | 25 ++++++ components/news.py | 93 +++++++++++++++++++++ components/overview.py | 97 ++++++++++++++++++++++ components/valuation.py | 208 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 505 insertions(+) create mode 100644 components/__init__.py create mode 100644 components/financials.py create mode 100644 components/market_bar.py create mode 100644 components/news.py create mode 100644 components/overview.py create mode 100644 components/valuation.py (limited to 'components') diff --git a/components/__init__.py b/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/financials.py b/components/financials.py new file mode 100644 index 0000000..547aedb --- /dev/null +++ b/components/financials.py @@ -0,0 +1,82 @@ +"""Financial statements — Income Statement, Balance Sheet, Cash Flow.""" +import pandas as pd +import streamlit as st +from services.data_service import get_income_statement, get_balance_sheet, get_cash_flow +from utils.formatters import fmt_large + + +def _format_statement(df: pd.DataFrame) -> pd.DataFrame: + """Format a yfinance financial statement for display.""" + if df.empty: + return df + + # Columns are datetime; convert to year strings + df = df.copy() + df.columns = [str(c)[:10] for c in df.columns] + + # Add YoY % change columns if >= 2 periods + cols = list(df.columns) + result = pd.DataFrame(index=df.index) + + for i, col in enumerate(cols): + result[col] = df[col].apply(_fmt_cell) + if i + 1 < len(cols): + prev_col = cols[i + 1] + yoy = df.apply( + lambda row: _yoy_pct(row[col], row[prev_col]), axis=1 + ) + result[f"YoY {col[:4]}"] = yoy + + return result + + +def _fmt_cell(value) -> str: + try: + v = float(value) + except (TypeError, ValueError): + return "—" + return fmt_large(v) + + +def _yoy_pct(current, previous) -> str: + try: + c, p = float(current), float(previous) + if p == 0: + return "—" + pct = (c - p) / abs(p) * 100 + arrow = "▲" if pct >= 0 else "▼" + return f"{arrow} {abs(pct):.1f}%" + except (TypeError, ValueError): + return "—" + + +def render_financials(ticker: str): + col1, col2 = st.columns([1, 3]) + with col1: + freq = st.radio("Frequency", ["Annual", "Quarterly"], horizontal=False) + quarterly = freq == "Quarterly" + + tab_income, tab_balance, tab_cashflow = st.tabs( + ["Income Statement", "Balance Sheet", "Cash Flow"] + ) + + with tab_income: + df = get_income_statement(ticker, quarterly=quarterly) + if df.empty: + st.info("Income statement data unavailable.") + else: + st.dataframe(_format_statement(df), use_container_width=True) + + with tab_balance: + df = get_balance_sheet(ticker, quarterly=quarterly) + if df.empty: + st.info("Balance sheet data unavailable.") + else: + st.dataframe(_format_statement(df), use_container_width=True) + + with tab_cashflow: + df = get_cash_flow(ticker, quarterly=quarterly) + if df.empty: + st.info("Cash flow data unavailable.") + else: + st.dataframe(_format_statement(df), use_container_width=True) diff --git a/components/market_bar.py b/components/market_bar.py new file mode 100644 index 0000000..cb813e5 --- /dev/null +++ b/components/market_bar.py @@ -0,0 +1,25 @@ +"""Market bar — displays major indices at the top of the app.""" +import streamlit as st +from services.data_service import get_market_indices +from utils.formatters import fmt_number + + +def render_market_bar(): + indices = get_market_indices() + + cols = st.columns(len(indices)) + for col, (name, data) in zip(cols, indices.items()): + price = data.get("price") + change_pct = data.get("change_pct") + + if price is None: + col.metric(label=name, value="—") + continue + + price_str = f"{price:,.2f}" + delta_str = f"{change_pct * 100:+.2f}%" if change_pct is not None else None + col.metric( + label=name, + value=price_str, + delta=delta_str, + ) diff --git a/components/news.py b/components/news.py new file mode 100644 index 0000000..cea678e --- /dev/null +++ b/components/news.py @@ -0,0 +1,93 @@ +"""News feed with sentiment badges.""" +import streamlit as st +from datetime import datetime +from services.news_service import get_company_news, get_news_sentiment +from services.fmp_service import get_company_news as get_fmp_news + + +def _sentiment_badge(sentiment: str) -> str: + badges = { + "bullish": "🟢 Bullish", + "bearish": "🔴 Bearish", + "neutral": "⚪ Neutral", + } + return badges.get(sentiment.lower(), "⚪ Neutral") + + +def _classify_sentiment(article: dict) -> str: + """Classify sentiment from Finnhub article data.""" + # Finnhub doesn't return per-article sentiment, use headline heuristics + positive = ["beats", "surges", "rises", "gains", "profit", "record", "upgrade", + "buy", "outperform", "growth", "strong", "higher", "rally"] + negative = ["misses", "falls", "drops", "loss", "cut", "downgrade", "sell", + "underperform", "weak", "lower", "decline", "warning", "layoff"] + headline = (article.get("headline") or article.get("title") or "").lower() + summary = (article.get("summary") or "").lower() + text = headline + " " + summary + pos = sum(1 for w in positive if w in text) + neg = sum(1 for w in negative if w in text) + if pos > neg: + return "bullish" + if neg > pos: + return "bearish" + return "neutral" + + +def _fmt_time(timestamp) -> str: + try: + if isinstance(timestamp, (int, float)): + return datetime.utcfromtimestamp(timestamp).strftime("%b %d, %Y") + return str(timestamp)[:10] + except Exception: + return "" + + +def render_news(ticker: str): + # Overall sentiment summary from Finnhub + sentiment_data = get_news_sentiment(ticker) + if sentiment_data: + buzz = sentiment_data.get("buzz", {}) + score = sentiment_data.get("sentiment", {}) + col1, col2, col3 = st.columns(3) + col1.metric("Articles (7d)", buzz.get("articlesInLastWeek", "—")) + bull_pct = score.get("bullishPercent") + bear_pct = score.get("bearishPercent") + col2.metric("Bullish %", f"{bull_pct * 100:.1f}%" if bull_pct else "—") + col3.metric("Bearish %", f"{bear_pct * 100:.1f}%" if bear_pct else "—") + st.divider() + + # Fetch articles — Finnhub first, FMP as fallback + articles = get_company_news(ticker) + if not articles: + articles = get_fmp_news(ticker) + + if not articles: + st.info("No recent news found.") + return + + for article in articles: + headline = article.get("headline") or article.get("title", "No title") + source = article.get("source") or article.get("site", "") + url = article.get("url") or article.get("newsURL") or article.get("url", "") + timestamp = article.get("datetime") or article.get("publishedDate", "") + summary = article.get("summary") or article.get("text") or "" + + sentiment = _classify_sentiment(article) + badge = _sentiment_badge(sentiment) + time_str = _fmt_time(timestamp) + + with st.container(): + col1, col2 = st.columns([5, 1]) + with col1: + if url: + st.markdown(f"**[{headline}]({url})**") + else: + st.markdown(f"**{headline}**") + meta = " · ".join(filter(None, [source, time_str])) + if meta: + st.caption(meta) + if summary: + st.caption(summary[:200] + ("…" if len(summary) > 200 else "")) + with col2: + st.markdown(badge) + st.divider() diff --git a/components/overview.py b/components/overview.py new file mode 100644 index 0000000..7407753 --- /dev/null +++ b/components/overview.py @@ -0,0 +1,97 @@ +"""Company overview — header, key stats, and price chart.""" +import streamlit as st +import plotly.graph_objects as go +from services.data_service import get_company_info, get_price_history +from utils.formatters import fmt_large, fmt_currency, fmt_pct, fmt_ratio + + +PERIODS = {"1 Month": "1mo", "3 Months": "3mo", "6 Months": "6mo", "1 Year": "1y", "5 Years": "5y"} + + +def render_overview(ticker: str): + info = get_company_info(ticker) + if not info: + st.error(f"Could not load data for **{ticker}**. Check the ticker symbol.") + return + + # ── Company header ────────────────────────────────────────────────────── + name = info.get("longName") or info.get("shortName", ticker.upper()) + price = info.get("currentPrice") or info.get("regularMarketPrice") + prev_close = info.get("regularMarketPreviousClose") or info.get("previousClose") + + price_change = None + price_change_pct = None + if price and prev_close: + price_change = price - prev_close + price_change_pct = price_change / prev_close + + col1, col2 = st.columns([3, 1]) + with col1: + st.subheader(f"{name} ({ticker.upper()})") + sector = info.get("sector", "") + industry = info.get("industry", "") + if sector: + st.caption(f"{sector} · {industry}") + + with col2: + delta_str = None + if price_change is not None and price_change_pct is not None: + delta_str = f"{price_change:+.2f} ({price_change_pct * 100:+.2f}%)" + st.metric( + label="Price", + value=fmt_currency(price) if price else "—", + delta=delta_str, + ) + + # ── Key stats strip ───────────────────────────────────────────────────── + stats_cols = st.columns(6) + stats = [ + ("Mkt Cap", fmt_large(info.get("marketCap"))), + ("P/E (TTM)", fmt_ratio(info.get("trailingPE"))), + ("EPS (TTM)", fmt_currency(info.get("trailingEps"))), + ("52W High", fmt_currency(info.get("fiftyTwoWeekHigh"))), + ("52W Low", fmt_currency(info.get("fiftyTwoWeekLow"))), + ("Beta", fmt_ratio(info.get("beta"))), + ] + for col, (label, val) in zip(stats_cols, stats): + col.metric(label, val) + + st.divider() + + # ── Price chart ───────────────────────────────────────────────────────── + period_label = st.radio( + "Period", + options=list(PERIODS.keys()), + index=3, + horizontal=True, + label_visibility="collapsed", + ) + period = PERIODS[period_label] + + hist = get_price_history(ticker, period=period) + if hist.empty: + st.warning("No price history available.") + return + + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=hist.index, + y=hist["Close"], + mode="lines", + name="Close", + line=dict(color="#4F8EF7", width=2), + fill="tozeroy", + fillcolor="rgba(79, 142, 247, 0.08)", + ) + ) + fig.update_layout( + margin=dict(l=0, r=0, t=10, b=0), + xaxis=dict(showgrid=False, zeroline=False), + yaxis=dict(showgrid=True, gridcolor="rgba(255,255,255,0.05)", zeroline=False), + plot_bgcolor="rgba(0,0,0,0)", + paper_bgcolor="rgba(0,0,0,0)", + hovermode="x unified", + height=320, + ) + st.plotly_chart(fig, use_container_width=True) 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, + ) -- cgit v1.3-2-g0d8e