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 --- services/__init__.py | 0 services/data_service.py | 109 ++++++++++++++++++++++++++++++++++++++++++ services/fmp_service.py | 65 +++++++++++++++++++++++++ services/news_service.py | 53 ++++++++++++++++++++ services/valuation_service.py | 82 +++++++++++++++++++++++++++++++ 5 files changed, 309 insertions(+) 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 (limited to 'services') 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, + } -- cgit v1.3-2-g0d8e