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 --- .env.example | 5 + .gitignore | 9 ++ .streamlit/config.toml | 13 +++ README.md | 119 ++++++++++++++++++++++++ app.py | 156 +++++++++++++++++++++++++++++++ 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 ++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 7 ++ services/__init__.py | 0 services/data_service.py | 109 ++++++++++++++++++++++ services/fmp_service.py | 65 +++++++++++++ services/news_service.py | 53 +++++++++++ services/valuation_service.py | 82 +++++++++++++++++ utils/__init__.py | 0 utils/formatters.py | 53 +++++++++++ 19 files changed, 1176 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .streamlit/config.toml create mode 100644 README.md create mode 100644 app.py 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 create mode 100644 requirements.txt create mode 100644 services/__init__.py create mode 100644 services/data_service.py create mode 100644 services/fmp_service.py create mode 100644 services/news_service.py create mode 100644 services/valuation_service.py create mode 100644 utils/__init__.py create mode 100644 utils/formatters.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..11e52e1 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Financial Modeling Prep โ€” free at https://financialmodelingprep.com/developer/docs +FMP_API_KEY=your_fmp_key_here + +# Finnhub โ€” free at https://finnhub.io +FINNHUB_API_KEY=your_finnhub_key_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d3df26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.venv/ +__pycache__/ +*.pyc +*.pyo +*.png +*.jpg +*.jpeg +.claude/ diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..e3fbcf9 --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,13 @@ +[theme] +base = "dark" +primaryColor = "#4F8EF7" +backgroundColor = "#0E1117" +secondaryBackgroundColor = "#161C27" +textColor = "#FAFAFA" +font = "sans serif" + +[server] +headless = false + +[browser] +gatherUsageStats = false diff --git a/README.md b/README.md new file mode 100644 index 0000000..c98a363 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# ๐Ÿ”ท Prism + +A local financial analysis dashboard. Enter any stock ticker to get a formatted view of financial statements, valuation metrics, a DCF model, and a news feed โ€” all in your browser. + +--- + +## Features + +- **Market Bar** โ€” Live S&P 500, NASDAQ, DOW, and VIX at the top of every page +- **Overview** โ€” Price chart (1M / 3M / 6M / 1Y / 5Y), key stats (market cap, P/E, 52W range, beta) +- **Financials** โ€” Annual and quarterly Income Statement, Balance Sheet, and Cash Flow Statement with year-over-year % change columns +- **Valuation** โ€” Key ratios grid (P/E, EV/EBITDA, margins, ROE, etc.), interactive DCF model with adjustable WACC and growth rate, comparable companies table +- **News** โ€” Recent articles with Bullish / Bearish / Neutral sentiment badges and a 7-day sentiment summary + +--- + +## Setup + +### 1. Clone / navigate to the project + +```bash +cd ~/Work/prism +``` + +### 2. Activate the virtual environment + +```bash +source .venv/bin/activate +``` + +### 3. Add API keys + +```bash +cp .env.example .env +``` + +Open `.env` and fill in your keys: + +``` +FMP_API_KEY=your_key_here +FINNHUB_API_KEY=your_key_here +``` + +Both are **free**: +- **FMP** (Financial Modeling Prep) โ€” [financialmodelingprep.com](https://financialmodelingprep.com/developer/docs) โ€” 250 requests/day +- **Finnhub** โ€” [finnhub.io](https://finnhub.io) โ€” 60 requests/minute + +> **No keys?** The app still works. Price data, financials, and market indices are sourced from `yfinance` (no key required). Ratios, comps, and news won't load until keys are added. + +### 4. Run the app + +```bash +streamlit run app.py +``` + +Opens at `http://localhost:8501` + +--- + +## Daily Usage + +```bash +cd ~/Work/prism +source .venv/bin/activate +streamlit run app.py +``` + +--- + +## Project Structure + +``` +prism/ +โ”œโ”€โ”€ app.py # Entry point +โ”œโ”€โ”€ requirements.txt +โ”œโ”€โ”€ .env # Your API keys (not committed) +โ”œโ”€โ”€ .env.example # Key template +โ”œโ”€โ”€ .streamlit/ +โ”‚ โ””โ”€โ”€ config.toml # Dark theme + layout settings +โ”œโ”€โ”€ services/ +โ”‚ โ”œโ”€โ”€ data_service.py # yfinance โ€” price, financials, indices +โ”‚ โ”œโ”€โ”€ fmp_service.py # FMP API โ€” ratios, peers +โ”‚ โ”œโ”€โ”€ news_service.py # Finnhub โ€” news + sentiment +โ”‚ โ””โ”€โ”€ valuation_service.py # DCF engine (Gordon Growth Model) +โ”œโ”€โ”€ components/ +โ”‚ โ”œโ”€โ”€ market_bar.py # Index metrics row +โ”‚ โ”œโ”€โ”€ overview.py # Company header + price chart +โ”‚ โ”œโ”€โ”€ financials.py # Statement tables +โ”‚ โ”œโ”€โ”€ valuation.py # Ratios, DCF, comps +โ”‚ โ””โ”€โ”€ news.py # News feed +โ””โ”€โ”€ utils/ + โ””โ”€โ”€ formatters.py # Number formatting helpers +``` + +--- + +## DCF Model Notes + +The DCF model uses **5 years of historical Free Cash Flow** from yfinance to compute an average growth rate, then projects forward using your chosen assumptions: + +| Input | Default | Range | +|---|---|---| +| WACC | 10% | 5โ€“20% | +| Terminal Growth Rate | 2.5% | 0.5โ€“5% | +| Projection Years | 5 | 3โ€“10 | + +The model uses the **Gordon Growth Model** for terminal value. Intrinsic value per share is compared against the current market price to show upside/downside. + +--- + +## API Rate Limits + +| Source | Limit | Used For | +|---|---|---| +| yfinance | None | Price, financials, indices | +| FMP (free) | 250 req/day | Ratios, comps, news | +| Finnhub (free) | 60 req/min | News, sentiment | + +Data is cached in-memory per session to minimize API calls (financials: 1h, news: 10min, indices: 5min). diff --git a/app.py b/app.py new file mode 100644 index 0000000..78b503f --- /dev/null +++ b/app.py @@ -0,0 +1,156 @@ +"""Prism โ€” Financial Analysis Dashboard""" +import streamlit as st +from dotenv import load_dotenv + +load_dotenv() + +st.set_page_config( + page_title="Prism", + page_icon="๐Ÿ”ท", + layout="wide", + initial_sidebar_state="expanded", +) + +# โ”€โ”€ Global CSS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +st.markdown(""" + +""", unsafe_allow_html=True) + +from components.market_bar import render_market_bar +from components.overview import render_overview +from components.financials import render_financials +from components.valuation import render_valuation +from components.news import render_news +from services.data_service import get_company_info, search_tickers + + +# โ”€โ”€ Sidebar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +with st.sidebar: + st.markdown("### ๐Ÿ”ท Prism") + st.caption("Financial Analysis Dashboard") + st.divider() + + # Search input + query = st.text_input( + "Search company or ticker", + placeholder="e.g. Apple, AAPL, MSFTโ€ฆ", + key="search_query", + ).strip() + + # Autocomplete: show results if query looks like a search term (not a bare ticker) + selected_symbol = None + if query: + results = search_tickers(query) + if results: + options = {f"{r['symbol']} โ€” {r['name']} ({r['exchange']})": r["symbol"] for r in results} + choice = st.selectbox( + "Select", + options=list(options.keys()), + label_visibility="collapsed", + ) + selected_symbol = options[choice] + else: + # Treat the raw input as a direct ticker + selected_symbol = query.upper() + + if st.button("Analyze", use_container_width=True, type="primary"): + if selected_symbol: + st.session_state["ticker"] = selected_symbol + + ticker = st.session_state.get("ticker", "AAPL") + + # Quick company info in sidebar + st.divider() + with st.spinner(""): + info = get_company_info(ticker) + if info: + st.caption(info.get("longName", ticker)) + st.caption(f"Exchange: {info.get('exchange', 'โ€”')}") + st.caption(f"Currency: {info.get('currency', 'USD')}") + st.caption(f"Sector: {info.get('sector', 'โ€”')}") + employees = info.get("fullTimeEmployees") + st.caption(f"Employees: {employees:,}" if isinstance(employees, int) else "Employees: โ€”") + website = info.get("website") + if website: + st.markdown(f"[๐ŸŒ Website]({website})") + + +# โ”€โ”€ Market Bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +with st.container(): + render_market_bar() + +st.divider() + +# โ”€โ”€ Main Content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +tab_overview, tab_financials, tab_valuation, tab_news = st.tabs([ + "๐Ÿ“ˆ Overview", + "๐Ÿ“Š Financials", + "๐Ÿ’ฐ Valuation", + "๐Ÿ“ฐ News", +]) + +with tab_overview: + try: + render_overview(ticker) + except Exception as e: + st.error(f"Overview failed to load: {e}") + +with tab_financials: + try: + render_financials(ticker) + except Exception as e: + st.error(f"Financials failed to load: {e}") + +with tab_valuation: + try: + render_valuation(ticker) + except Exception as e: + st.error(f"Valuation failed to load: {e}") + +with tab_news: + try: + render_news(ticker) + except Exception as e: + st.error(f"News failed to load: {e}") 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, + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e725993 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +streamlit>=1.40.0 +yfinance>=0.2.50 +pandas>=2.2.0 +numpy>=1.26.0 +plotly>=5.24.0 +requests>=2.32.0 +python-dotenv>=1.0.0 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/data_service.py b/services/data_service.py new file mode 100644 index 0000000..fa9b026 --- /dev/null +++ b/services/data_service.py @@ -0,0 +1,109 @@ +"""yfinance wrapper โ€” price history, financial statements, company info.""" +import yfinance as yf +import pandas as pd +import streamlit as st + + +@st.cache_data(ttl=60) +def search_tickers(query: str) -> list[dict]: + """Search for tickers by company name or symbol. Returns list of {symbol, name, exchange}.""" + if not query or len(query.strip()) < 2: + return [] + try: + results = yf.Search(query.strip(), max_results=8).quotes + out = [] + for r in results: + symbol = r.get("symbol", "") + name = r.get("longname") or r.get("shortname") or symbol + exchange = r.get("exchange") or r.get("exchDisp", "") + if symbol: + out.append({"symbol": symbol, "name": name, "exchange": exchange}) + return out + except Exception: + return [] + + +@st.cache_data(ttl=300) +def get_company_info(ticker: str) -> dict: + """Return company info dict from yfinance.""" + t = yf.Ticker(ticker.upper()) + info = t.info or {} + return info + + +@st.cache_data(ttl=300) +def get_price_history(ticker: str, period: str = "1y") -> pd.DataFrame: + """Return OHLCV price history.""" + t = yf.Ticker(ticker.upper()) + df = t.history(period=period) + df.index = pd.to_datetime(df.index) + return df + + +@st.cache_data(ttl=3600) +def get_income_statement(ticker: str, quarterly: bool = False) -> pd.DataFrame: + t = yf.Ticker(ticker.upper()) + df = t.quarterly_income_stmt if quarterly else t.income_stmt + return df if df is not None else pd.DataFrame() + + +@st.cache_data(ttl=3600) +def get_balance_sheet(ticker: str, quarterly: bool = False) -> pd.DataFrame: + t = yf.Ticker(ticker.upper()) + df = t.quarterly_balance_sheet if quarterly else t.balance_sheet + return df if df is not None else pd.DataFrame() + + +@st.cache_data(ttl=3600) +def get_cash_flow(ticker: str, quarterly: bool = False) -> pd.DataFrame: + t = yf.Ticker(ticker.upper()) + df = t.quarterly_cashflow if quarterly else t.cashflow + return df if df is not None else pd.DataFrame() + + +@st.cache_data(ttl=300) +def get_market_indices() -> dict: + """Return latest price + day change % for major indices.""" + symbols = { + "S&P 500": "^GSPC", + "NASDAQ": "^IXIC", + "DOW": "^DJI", + "VIX": "^VIX", + } + result = {} + for name, sym in symbols.items(): + try: + t = yf.Ticker(sym) + hist = t.history(period="2d") + if len(hist) >= 2: + prev_close = hist["Close"].iloc[-2] + last = hist["Close"].iloc[-1] + pct_change = (last - prev_close) / prev_close + elif len(hist) == 1: + last = hist["Close"].iloc[-1] + pct_change = 0.0 + else: + result[name] = {"price": None, "change_pct": None} + continue + result[name] = {"price": float(last), "change_pct": float(pct_change)} + except Exception: + result[name] = {"price": None, "change_pct": None} + return result + + +@st.cache_data(ttl=3600) +def get_free_cash_flow_series(ticker: str) -> pd.Series: + """Return annual Free Cash Flow series (most recent first).""" + t = yf.Ticker(ticker.upper()) + cf = t.cashflow + if cf is None or cf.empty: + return pd.Series(dtype=float) + if "Free Cash Flow" in cf.index: + return cf.loc["Free Cash Flow"].dropna() + # Compute from operating CF - capex + try: + op = cf.loc["Operating Cash Flow"] + capex = cf.loc["Capital Expenditure"] + return (op + capex).dropna() + except KeyError: + return pd.Series(dtype=float) diff --git a/services/fmp_service.py b/services/fmp_service.py new file mode 100644 index 0000000..bf31788 --- /dev/null +++ b/services/fmp_service.py @@ -0,0 +1,65 @@ +"""Financial Modeling Prep API โ€” ratios, peers, company news.""" +import os +import requests +import streamlit as st +from dotenv import load_dotenv + +load_dotenv() + +BASE_URL = "https://financialmodelingprep.com/api/v3" + + +def _api_key() -> str: + key = os.getenv("FMP_API_KEY", "") + return key + + +def _get(endpoint: str, params: dict = None) -> dict | list | None: + params = params or {} + params["apikey"] = _api_key() + try: + resp = requests.get(f"{BASE_URL}{endpoint}", params=params, timeout=10) + resp.raise_for_status() + return resp.json() + except Exception: + return None + + +@st.cache_data(ttl=3600) +def get_key_ratios(ticker: str) -> dict: + """Return latest TTM key ratios.""" + data = _get(f"/ratios-ttm/{ticker.upper()}") + if data and isinstance(data, list) and len(data) > 0: + return data[0] + return {} + + +@st.cache_data(ttl=21600) +def get_peers(ticker: str) -> list[str]: + """Return list of comparable ticker symbols.""" + data = _get(f"/stock_peers", params={"symbol": ticker.upper()}) + if data and isinstance(data, list) and len(data) > 0: + return data[0].get("peersList", []) + return [] + + +@st.cache_data(ttl=3600) +def get_ratios_for_tickers(tickers: list[str]) -> list[dict]: + """Return TTM ratios for a list of tickers (for comps table).""" + results = [] + for t in tickers: + data = _get(f"/ratios-ttm/{t}") + if data and isinstance(data, list) and len(data) > 0: + row = data[0] + row["symbol"] = t + results.append(row) + return results + + +@st.cache_data(ttl=600) +def get_company_news(ticker: str, limit: int = 20) -> list[dict]: + """Return recent news articles for a ticker.""" + data = _get("/stock_news", params={"tickers": ticker.upper(), "limit": limit}) + if data and isinstance(data, list): + return data + return [] diff --git a/services/news_service.py b/services/news_service.py new file mode 100644 index 0000000..b060c54 --- /dev/null +++ b/services/news_service.py @@ -0,0 +1,53 @@ +"""Finnhub news service โ€” company news with sentiment.""" +import os +import time +import requests +import streamlit as st +from dotenv import load_dotenv + +load_dotenv() + +BASE_URL = "https://finnhub.io/api/v1" + + +def _api_key() -> str: + return os.getenv("FINNHUB_API_KEY", "") + + +def _get(endpoint: str, params: dict = None) -> dict | list | None: + params = params or {} + params["token"] = _api_key() + try: + resp = requests.get(f"{BASE_URL}{endpoint}", params=params, timeout=10) + resp.raise_for_status() + return resp.json() + except Exception: + return None + + +@st.cache_data(ttl=600) +def get_company_news(ticker: str, days_back: int = 7) -> list[dict]: + """Return recent news articles with sentiment for a ticker.""" + end = int(time.time()) + start = end - days_back * 86400 + from datetime import datetime + date_from = datetime.utcfromtimestamp(start).strftime("%Y-%m-%d") + date_to = datetime.utcfromtimestamp(end).strftime("%Y-%m-%d") + + data = _get("/company-news", params={ + "symbol": ticker.upper(), + "from": date_from, + "to": date_to, + }) + if not data or not isinstance(data, list): + return [] + return data[:30] + + +@st.cache_data(ttl=600) +def get_news_sentiment(ticker: str) -> dict: + """Return sentiment scores for a ticker.""" + data = _get("/news-sentiment", params={"symbol": ticker.upper()}) + if data and isinstance(data, dict): + return data + return {} diff --git a/services/valuation_service.py b/services/valuation_service.py new file mode 100644 index 0000000..f876f78 --- /dev/null +++ b/services/valuation_service.py @@ -0,0 +1,82 @@ +"""DCF valuation engine โ€” Gordon Growth Model.""" +import numpy as np +import pandas as pd + + +def run_dcf( + fcf_series: pd.Series, + shares_outstanding: float, + wacc: float = 0.10, + terminal_growth: float = 0.03, + projection_years: int = 5, +) -> dict: + """ + Run a DCF model and return per-year breakdown plus intrinsic value per share. + + Args: + fcf_series: Annual FCF values, most recent first (yfinance order). + shares_outstanding: Diluted shares outstanding. + wacc: Weighted average cost of capital (decimal, e.g. 0.10). + terminal_growth: Perpetuity growth rate (decimal, e.g. 0.03). + projection_years: Number of years to project FCFs. + + Returns: + dict with keys: + intrinsic_value_per_share, total_pv, terminal_value_pv, + fcf_pv_sum, years, projected_fcfs, discounted_fcfs, + growth_rate_used + """ + if fcf_series.empty or shares_outstanding <= 0: + return {} + + # Use last N years of FCF (sorted oldest โ†’ newest) + historical = fcf_series.sort_index().dropna().values + if len(historical) < 2: + return {} + + # Compute average YoY growth rate from historical FCF + growth_rates = [] + for i in range(1, len(historical)): + if historical[i - 1] != 0: + g = (historical[i] - historical[i - 1]) / abs(historical[i - 1]) + growth_rates.append(g) + + # Cap growth rate to reasonable bounds [-0.5, 0.5] + raw_growth = float(np.median(growth_rates)) if growth_rates else 0.05 + growth_rate = max(-0.50, min(0.50, raw_growth)) + + base_fcf = float(historical[-1]) # most recent FCF + + # Project FCFs + projected_fcfs = [] + for year in range(1, projection_years + 1): + fcf = base_fcf * ((1 + growth_rate) ** year) + projected_fcfs.append(fcf) + + # Discount projected FCFs + discounted_fcfs = [] + for i, fcf in enumerate(projected_fcfs, start=1): + pv = fcf / ((1 + wacc) ** i) + discounted_fcfs.append(pv) + + fcf_pv_sum = sum(discounted_fcfs) + + # Terminal value (Gordon Growth Model) + terminal_fcf = projected_fcfs[-1] * (1 + terminal_growth) + terminal_value = terminal_fcf / (wacc - terminal_growth) + terminal_value_pv = terminal_value / ((1 + wacc) ** projection_years) + + total_pv = fcf_pv_sum + terminal_value_pv + intrinsic_value_per_share = total_pv / shares_outstanding + + return { + "intrinsic_value_per_share": intrinsic_value_per_share, + "total_pv": total_pv, + "terminal_value_pv": terminal_value_pv, + "fcf_pv_sum": fcf_pv_sum, + "years": list(range(1, projection_years + 1)), + "projected_fcfs": projected_fcfs, + "discounted_fcfs": discounted_fcfs, + "growth_rate_used": growth_rate, + "base_fcf": base_fcf, + } diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/formatters.py b/utils/formatters.py new file mode 100644 index 0000000..c25fbb9 --- /dev/null +++ b/utils/formatters.py @@ -0,0 +1,53 @@ +def fmt_large(value) -> str: + """Format a number in billions/millions with suffix.""" + try: + v = float(value) + except (TypeError, ValueError): + return "N/A" + if abs(v) >= 1e12: + return f"${v / 1e12:.2f}T" + if abs(v) >= 1e9: + return f"${v / 1e9:.2f}B" + if abs(v) >= 1e6: + return f"${v / 1e6:.2f}M" + return f"${v:,.0f}" + + +def fmt_currency(value) -> str: + """Format a dollar amount with commas.""" + try: + return f"${float(value):,.2f}" + except (TypeError, ValueError): + return "N/A" + + +def fmt_pct(value, decimals: int = 2) -> str: + """Format a ratio as a percentage (0.12 โ†’ 12.00%).""" + try: + return f"{float(value) * 100:.{decimals}f}%" + except (TypeError, ValueError): + return "N/A" + + +def fmt_ratio(value, decimals: int = 2) -> str: + """Format a plain ratio/multiple (e.g. P/E).""" + try: + return f"{float(value):.{decimals}f}x" + except (TypeError, ValueError): + return "N/A" + + +def fmt_number(value, decimals: int = 2) -> str: + """Format a plain number.""" + try: + return f"{float(value):,.{decimals}f}" + except (TypeError, ValueError): + return "N/A" + + +def delta_color(value) -> str: + """Return 'normal', 'inverse' or 'off' for st.metric delta_color.""" + try: + return "normal" if float(value) >= 0 else "inverse" + except (TypeError, ValueError): + return "off" -- cgit v1.3-2-g0d8e