aboutsummaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-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
6 files changed, 505 insertions, 0 deletions
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,
+ )