aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.example5
-rw-r--r--.gitignore9
-rw-r--r--.streamlit/config.toml13
-rw-r--r--README.md119
-rw-r--r--app.py156
-rw-r--r--components/__init__.py0
-rw-r--r--components/financials.py82
-rw-r--r--components/market_bar.py25
-rw-r--r--components/news.py93
-rw-r--r--components/overview.py97
-rw-r--r--components/valuation.py208
-rw-r--r--requirements.txt7
-rw-r--r--services/__init__.py0
-rw-r--r--services/data_service.py109
-rw-r--r--services/fmp_service.py65
-rw-r--r--services/news_service.py53
-rw-r--r--services/valuation_service.py82
-rw-r--r--utils/__init__.py0
-rw-r--r--utils/formatters.py53
19 files changed, 1176 insertions, 0 deletions
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("""
+<style>
+ /* Tighten metric cards */
+ [data-testid="metric-container"] {
+ padding: 0.4rem 0.6rem !important;
+ }
+ [data-testid="stMetricLabel"] {
+ font-size: 0.72rem !important;
+ font-weight: 500;
+ color: #9aa0b0 !important;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ }
+ [data-testid="stMetricValue"] {
+ font-size: 1.1rem !important;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ }
+ [data-testid="stMetricDelta"] svg { width: 0.75rem; height: 0.75rem; }
+ [data-testid="stMetricDelta"] > div { font-size: 0.78rem !important; }
+
+ /* Tighter headings */
+ h1 { font-size: 1.5rem !important; font-weight: 700 !important; }
+ h2 { font-size: 1.25rem !important; font-weight: 600 !important; }
+ h3 { font-size: 1.05rem !important; font-weight: 600 !important; }
+
+ /* Tighter sidebar text */
+ [data-testid="stSidebar"] .stCaption p {
+ font-size: 0.78rem !important;
+ line-height: 1.5 !important;
+ }
+
+ /* Slim dividers */
+ hr { margin: 0.5rem 0 !important; }
+
+ /* Tab labels */
+ [data-testid="stTab"] p { font-size: 0.85rem !important; font-weight: 500; }
+
+ /* Reduce default top padding */
+ .block-container { padding-top: 1rem !important; }
+</style>
+""", 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
--- /dev/null
+++ b/components/__init__.py
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
--- /dev/null
+++ b/services/__init__.py
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
--- /dev/null
+++ b/utils/__init__.py
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"