aboutsummaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-03-28 23:01:14 -0700
committerTyler <tyler@tylerhoang.xyz>2026-03-28 23:01:14 -0700
commit23675b39b8055a8568cdcf71f66482b9d0cf90a9 (patch)
tree14e42cf710b47072e904b1c21d7322352ae1823c /services
Initial commit — Prism financial analysis dashboard
Streamlit app with market bar, price chart, financial statements, DCF valuation engine, comparable companies, and news feed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'services')
-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
5 files changed, 309 insertions, 0 deletions
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,
+ }