diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/filings.py | 115 | ||||
| -rw-r--r-- | components/insiders.py | 161 | ||||
| -rw-r--r-- | components/valuation.py | 277 |
3 files changed, 550 insertions, 3 deletions
diff --git a/components/filings.py b/components/filings.py new file mode 100644 index 0000000..d366a2b --- /dev/null +++ b/components/filings.py @@ -0,0 +1,115 @@ +"""SEC filings — recent 10-K, 10-Q, 8-K and other forms with direct links.""" +import pandas as pd +import streamlit as st + +from services.fmp_service import get_sec_filings + + +_FORM_DESCRIPTIONS = { + "10-K": "Annual report", + "10-Q": "Quarterly report", + "8-K": "Material event disclosure", + "DEF 14A": "Proxy statement", + "S-1": "IPO registration", + "S-3": "Securities registration", + "4": "Insider ownership change", + "SC 13G": "Beneficial ownership (passive)", + "SC 13D": "Beneficial ownership (active)", +} + +_FORM_COLORS = { + "10-K": "rgba(79,142,247,0.15)", + "10-Q": "rgba(130,224,170,0.15)", + "8-K": "rgba(247,162,79,0.15)", +} + + +def _describe_form(form_type: str) -> str: + return _FORM_DESCRIPTIONS.get(form_type.strip().upper(), "") + + +def render_filings(ticker: str): + with st.spinner("Loading SEC filings…"): + raw = get_sec_filings(ticker) + + if not raw: + st.info("No SEC filing data available. Requires FMP API key.") + return + + # Collect unique form types for filter + form_types = sorted({str(r.get("type") or r.get("form") or "").strip() for r in raw if r.get("type") or r.get("form")}) + filter_options = ["All"] + [t for t in ["10-K", "10-Q", "8-K"] if t in form_types] + \ + [t for t in form_types if t not in ("10-K", "10-Q", "8-K")] + + filter_col, _ = st.columns([1, 3]) + with filter_col: + selected_type = st.selectbox("Form type", options=filter_options, index=0, key="filings_filter") + + # Summary counts + counts = {} + for r in raw: + ft = str(r.get("type") or r.get("form") or "Other").strip() + counts[ft] = counts.get(ft, 0) + 1 + + priority_forms = ["10-K", "10-Q", "8-K"] + summary_forms = [f for f in priority_forms if f in counts] + if summary_forms: + cols = st.columns(len(summary_forms)) + for col, ft in zip(cols, summary_forms): + col.metric(ft, counts[ft]) + st.write("") + + # Filter + if selected_type == "All": + filtered = raw + else: + filtered = [ + r for r in raw + if str(r.get("type") or r.get("form") or "").strip() == selected_type + ] + + if not filtered: + st.info("No filings match the current filter.") + return + + # Build display table + rows = [] + for item in filtered: + form_type = str(item.get("type") or item.get("form") or "—").strip() + date = str(item.get("filingDate") or item.get("date") or "")[:10] + description = item.get("description") or _describe_form(form_type) or "—" + url = item.get("link") or item.get("finalLink") or item.get("url") or "" + rows.append({ + "Date": date, + "Form": form_type, + "Description": description, + "Link": url, + }) + + df = pd.DataFrame(rows) + + # Render as clickable table — Streamlit doesn't natively support link columns, + # so we render each row as a compact card for the most recent 30 entries. + for _, row in df.head(30).iterrows(): + form = row["Form"] + color = _FORM_COLORS.get(form, "rgba(255,255,255,0.05)") + date = row["Date"] + desc = row["Description"] + link = row["Link"] + + with st.container(): + left, right = st.columns([5, 1]) + with left: + st.markdown( + f"<div style='background:{color};padding:6px 10px;border-radius:4px;margin-bottom:2px'>" + f"<strong>{form}</strong> · <span style='color:#9aa0b0;font-size:0.82rem'>{date}</span><br>" + f"<span style='font-size:0.85rem'>{desc}</span>" + f"</div>", + unsafe_allow_html=True, + ) + with right: + if link: + st.markdown( + f"<div style='padding-top:8px'><a href='{link}' target='_blank'>🔗 View</a></div>", + unsafe_allow_html=True, + ) diff --git a/components/insiders.py b/components/insiders.py new file mode 100644 index 0000000..05561ba --- /dev/null +++ b/components/insiders.py @@ -0,0 +1,161 @@ +"""Insider transactions — recent buys/sells with summary and detail table.""" +import pandas as pd +import plotly.graph_objects as go +import streamlit as st +from datetime import datetime, timedelta + +from services.fmp_service import get_insider_transactions +from utils.formatters import fmt_currency, fmt_large + + +def _parse_date(date_str: str) -> datetime | None: + for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(str(date_str)[:19], fmt) + except ValueError: + continue + return None + + +def _classify(row: dict) -> str: + """Return 'Buy' or 'Sell' from the transaction type field.""" + tx = str(row.get("transactionType") or row.get("acquistionOrDisposition") or "").strip() + buy_keywords = ("purchase", "p -", "p-", "acquisition", "a -", "a-", "grant", "award", "exercise") + sell_keywords = ("sale", "s -", "s-", "disposition", "d -", "d-") + tx_lower = tx.lower() + if any(k in tx_lower for k in sell_keywords): + return "Sell" + if any(k in tx_lower for k in buy_keywords): + return "Buy" + # Fallback: FMP often uses acquistionOrDisposition = "A" or "D" + aod = str(row.get("acquistionOrDisposition") or "").strip().upper() + if aod == "D": + return "Sell" + if aod == "A": + return "Buy" + return "Other" + + +def render_insiders(ticker: str): + with st.spinner("Loading insider transactions…"): + raw = get_insider_transactions(ticker) + + if not raw: + st.info("No insider transaction data available. Requires FMP API key.") + return + + # Build enriched rows + rows = [] + for item in raw: + direction = _classify(item) + shares = item.get("securitiesTransacted") or item.get("shares") or 0 + price = item.get("price") or 0.0 + try: + value = float(shares) * float(price) + except (TypeError, ValueError): + value = 0.0 + date_obj = _parse_date(str(item.get("transactionDate") or item.get("filingDate") or "")) + rows.append({ + "date": date_obj, + "date_str": str(item.get("transactionDate") or "")[:10], + "name": item.get("reportingName") or item.get("insiderName") or "—", + "title": item.get("typeOfOwner") or item.get("title") or "—", + "direction": direction, + "shares": shares, + "price": price, + "value": value, + "filing_url": item.get("link") or item.get("secLink") or "", + }) + + # ── Summary: last 6 months ──────────────────────────────────────────── + cutoff = datetime.now() - timedelta(days=180) + recent = [r for r in rows if r["date"] and r["date"] >= cutoff] + + buys = [r for r in recent if r["direction"] == "Buy"] + sells = [r for r in recent if r["direction"] == "Sell"] + total_buy_val = sum(r["value"] for r in buys) + total_sell_val = sum(r["value"] for r in sells) + + st.markdown("**Insider Activity — Last 6 Months**") + c1, c2, c3, c4 = st.columns(4) + c1.metric("Buy Transactions", len(buys)) + c2.metric("Total Bought", fmt_large(total_buy_val) if total_buy_val else "—") + c3.metric("Sell Transactions", len(sells)) + c4.metric("Total Sold", fmt_large(total_sell_val) if total_sell_val else "—") + + # Net buy/sell bar chart (monthly) + if recent: + monthly: dict[str, dict] = {} + for r in recent: + if r["date"]: + key = r["date"].strftime("%Y-%m") + monthly.setdefault(key, {"Buy": 0.0, "Sell": 0.0}) + if r["direction"] in ("Buy", "Sell"): + monthly[key][r["direction"]] += r["value"] + + months = sorted(monthly.keys()) + buy_vals = [monthly[m]["Buy"] / 1e6 for m in months] + sell_vals = [-monthly[m]["Sell"] / 1e6 for m in months] + + fig = go.Figure() + fig.add_trace(go.Bar( + x=months, y=buy_vals, + name="Buys", + marker_color="#2ecc71", + )) + fig.add_trace(go.Bar( + x=months, y=sell_vals, + name="Sells", + marker_color="#e74c3c", + )) + fig.update_layout( + title="Monthly Insider Net Activity ($M)", + barmode="relative", + yaxis_title="Value ($M)", + 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=280, + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + ) + st.plotly_chart(fig, use_container_width=True) + + st.divider() + + # ── Transaction table ───────────────────────────────────────────────── + st.markdown("**All Transactions**") + + filter_col, _ = st.columns([1, 3]) + with filter_col: + direction_filter = st.selectbox( + "Filter", options=["All", "Buy", "Sell"], index=0, key="insider_filter" + ) + + filtered = rows if direction_filter == "All" else [r for r in rows if r["direction"] == direction_filter] + + if not filtered: + st.info("No transactions match the current filter.") + return + + df = pd.DataFrame([{ + "Date": r["date_str"], + "Insider": r["name"], + "Title": r["title"], + "Type": r["direction"], + "Shares": f"{int(r['shares']):,}" if r["shares"] else "—", + "Price": fmt_currency(r["price"]) if r["price"] else "—", + "Value": fmt_large(r["value"]) if r["value"] else "—", + } for r in filtered]) + + def _color_type(row): + if row["Type"] == "Buy": + return [""] * 2 + ["background-color: rgba(46,204,113,0.15)"] + [""] * 4 + if row["Type"] == "Sell": + return [""] * 2 + ["background-color: rgba(231,76,60,0.15)"] + [""] * 4 + return [""] * len(row) + + st.dataframe( + df.style.apply(_color_type, axis=1), + use_container_width=True, + hide_index=True, + ) diff --git a/components/valuation.py b/components/valuation.py index 170ae1f..0c50a28 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -10,7 +10,14 @@ from services.data_service import ( get_earnings_history, get_next_earnings_date, ) -from services.fmp_service import get_key_ratios, get_peers, get_ratios_for_tickers +from services.fmp_service import ( + get_key_ratios, + get_peers, + get_ratios_for_tickers, + get_historical_ratios, + get_historical_key_metrics, + get_analyst_estimates, +) from services.valuation_service import run_dcf, run_ev_ebitda, compute_historical_growth_rate from utils.formatters import fmt_ratio, fmt_pct, fmt_large, fmt_currency @@ -80,19 +87,38 @@ def _suggest_peer_tickers(ticker: str, info: dict) -> list[str]: def render_valuation(ticker: str): - tab_ratios, tab_dcf, tab_comps, tab_analyst, tab_earnings = st.tabs([ - "Key Ratios", "DCF Model", "Comps", "Analyst Targets", "Earnings History" + tabs = st.tabs([ + "Key Ratios", + "Historical Ratios", + "DCF Model", + "Comps", + "Forward Estimates", + "Analyst Targets", + "Earnings History", ]) + tab_ratios, tab_hist, tab_dcf, tab_comps, tab_fwd, tab_analyst, tab_earnings = tabs with tab_ratios: _render_ratios(ticker) + with tab_hist: + try: + _render_historical_ratios(ticker) + except Exception as e: + st.error(f"Historical ratios unavailable: {e}") + with tab_dcf: _render_dcf(ticker) with tab_comps: _render_comps(ticker) + with tab_fwd: + try: + _render_forward_estimates(ticker) + except Exception as e: + st.error(f"Forward estimates unavailable: {e}") + with tab_analyst: try: _render_analyst_targets(ticker) @@ -569,3 +595,248 @@ def _render_earnings_history(ticker: str): legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), ) st.plotly_chart(fig, use_container_width=True) + + +# ── Historical Ratios ──────────────────────────────────────────────────────── + +_HIST_RATIO_OPTIONS = { + "P/E": ("peRatio", "priceToEarningsRatio", None), + "P/B": ("priceToBookRatio", None, None), + "P/S": ("priceToSalesRatio", None, None), + "EV/EBITDA": ("enterpriseValueMultiple", "evToEBITDA", None), + "Net Margin": ("netProfitMargin", None, "pct"), + "Operating Margin": ("operatingProfitMargin", None, "pct"), + "Gross Margin": ("grossProfitMargin", None, "pct"), + "ROE": ("returnOnEquity", None, "pct"), + "ROA": ("returnOnAssets", None, "pct"), + "Debt/Equity": ("debtEquityRatio", None, None), +} + +_CHART_COLORS = [ + "#4F8EF7", "#F7A24F", "#2ecc71", "#e74c3c", + "#9b59b6", "#1abc9c", "#f39c12", "#e67e22", +] + + +def _extract_hist_series(rows: list[dict], primary: str, alt: str | None) -> dict[str, float]: + """Extract {year: value} from FMP historical rows.""" + out = {} + for row in rows: + date = str(row.get("date", ""))[:4] + val = row.get(primary) + if val is None and alt: + val = row.get(alt) + if val is not None: + try: + out[date] = float(val) + except (TypeError, ValueError): + pass + return out + + +def _render_historical_ratios(ticker: str): + with st.spinner("Loading historical ratios…"): + ratio_rows = get_historical_ratios(ticker) + metric_rows = get_historical_key_metrics(ticker) + + if not ratio_rows and not metric_rows: + st.info("Historical ratio data unavailable. Requires FMP API key.") + return + + # Merge both lists by date + combined: dict[str, dict] = {} + for row in ratio_rows + metric_rows: + date = str(row.get("date", ""))[:4] + if date: + combined.setdefault(date, {}).update(row) + + merged_rows = [{"date": d, **v} for d, v in sorted(combined.items(), reverse=True)] + + selected = st.multiselect( + "Metrics to plot", + options=list(_HIST_RATIO_OPTIONS.keys()), + default=["P/E", "EV/EBITDA", "Net Margin", "ROE"], + ) + + if not selected: + st.info("Select at least one metric to plot.") + return + + fig = go.Figure() + for i, label in enumerate(selected): + primary, alt, fmt = _HIST_RATIO_OPTIONS[label] + series = _extract_hist_series(merged_rows, primary, alt) + if not series: + continue + years = sorted(series.keys()) + values = [series[y] * (100 if fmt == "pct" else 1) for y in years] + y_label = f"{label} (%)" if fmt == "pct" else label + fig.add_trace(go.Scatter( + x=years, + y=values, + name=y_label, + mode="lines+markers", + line=dict(color=_CHART_COLORS[i % len(_CHART_COLORS)], width=2), + )) + + fig.update_layout( + title="Historical Ratios & Metrics", + xaxis_title="Year", + 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=380, + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + hovermode="x unified", + ) + st.plotly_chart(fig, use_container_width=True) + + # Raw data table + with st.expander("Raw data"): + display_cols = {} + for label in selected: + primary, alt, fmt = _HIST_RATIO_OPTIONS[label] + display_cols[label] = (primary, alt, fmt) + + table_rows = [] + for row in merged_rows: + r: dict = {"Year": str(row.get("date", ""))[:4]} + for label, (primary, alt, fmt) in display_cols.items(): + val = row.get(primary) or (row.get(alt) if alt else None) + if val is not None: + try: + v = float(val) + r[label] = f"{v * 100:.2f}%" if fmt == "pct" else f"{v:.2f}x" + except (TypeError, ValueError): + r[label] = "—" + else: + r[label] = "—" + table_rows.append(r) + + if table_rows: + st.dataframe(pd.DataFrame(table_rows), use_container_width=True, hide_index=True) + + +# ── Forward Estimates ──────────────────────────────────────────────────────── + +def _render_forward_estimates(ticker: str): + with st.spinner("Loading forward estimates…"): + estimates = get_analyst_estimates(ticker) + + annual = estimates.get("annual", []) + quarterly = estimates.get("quarterly", []) + + if not annual and not quarterly: + st.info("Forward estimates unavailable. Requires FMP API key.") + return + + info = get_company_info(ticker) + current_price = info.get("currentPrice") or info.get("regularMarketPrice") + + tab_ann, tab_qtr = st.tabs(["Annual", "Quarterly"]) + + def _build_estimates_table(rows: list[dict]) -> pd.DataFrame: + table = [] + for row in sorted(rows, key=lambda r: str(r.get("date", ""))): + date = str(row.get("date", ""))[:7] + rev_avg = row.get("estimatedRevenueAvg") + rev_lo = row.get("estimatedRevenueLow") + rev_hi = row.get("estimatedRevenueHigh") + eps_avg = row.get("estimatedEpsAvg") + eps_lo = row.get("estimatedEpsLow") + eps_hi = row.get("estimatedEpsHigh") + ebitda_avg = row.get("estimatedEbitdaAvg") + num_analysts = row.get("numberAnalystEstimatedRevenue") or row.get("numberAnalysts") + table.append({ + "Period": date, + "Rev Low": fmt_large(rev_lo) if rev_lo else "—", + "Rev Avg": fmt_large(rev_avg) if rev_avg else "—", + "Rev High": fmt_large(rev_hi) if rev_hi else "—", + "EPS Low": fmt_currency(eps_lo) if eps_lo else "—", + "EPS Avg": fmt_currency(eps_avg) if eps_avg else "—", + "EPS High": fmt_currency(eps_hi) if eps_hi else "—", + "EBITDA Avg": fmt_large(ebitda_avg) if ebitda_avg else "—", + "# Analysts": str(int(num_analysts)) if num_analysts else "—", + }) + return pd.DataFrame(table) + + def _render_eps_chart(rows: list[dict], title: str): + """Overlay historical EPS actuals with forward estimates.""" + eh = get_earnings_history(ticker) + fwd_dates, fwd_eps = [], [] + for row in sorted(rows, key=lambda r: str(r.get("date", ""))): + date = str(row.get("date", ""))[:7] + eps = row.get("estimatedEpsAvg") + eps_lo = row.get("estimatedEpsLow") + eps_hi = row.get("estimatedEpsHigh") + if eps is not None: + fwd_dates.append(date) + fwd_eps.append(float(eps)) + + fig = go.Figure() + + if eh is not None and not eh.empty: + hist = eh.sort_index() + fig.add_trace(go.Scatter( + x=hist.index.astype(str), + y=hist["epsActual"], + name="EPS Actual", + mode="lines+markers", + line=dict(color="#4F8EF7", width=2), + )) + + if fwd_dates: + # Low/high band + fwd_lo = [float(r["estimatedEpsLow"]) for r in sorted(rows, key=lambda r: str(r.get("date", ""))) + if r.get("estimatedEpsLow") is not None] + fwd_hi = [float(r["estimatedEpsHigh"]) for r in sorted(rows, key=lambda r: str(r.get("date", ""))) + if r.get("estimatedEpsHigh") is not None] + + if fwd_lo and fwd_hi and len(fwd_lo) == len(fwd_dates): + fig.add_trace(go.Scatter( + x=fwd_dates + fwd_dates[::-1], + y=fwd_hi + fwd_lo[::-1], + fill="toself", + fillcolor="rgba(247,162,79,0.15)", + line=dict(color="rgba(0,0,0,0)"), + name="Est. Range", + hoverinfo="skip", + )) + + fig.add_trace(go.Scatter( + x=fwd_dates, + y=fwd_eps, + name="EPS Est. (Avg)", + mode="lines+markers", + line=dict(color="#F7A24F", width=2, dash="dash"), + )) + + fig.update_layout( + title=title, + yaxis_title="EPS ($)", + 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=320, + hovermode="x unified", + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + ) + st.plotly_chart(fig, use_container_width=True) + + with tab_ann: + if annual: + df = _build_estimates_table(annual) + st.dataframe(df, use_container_width=True, hide_index=True) + st.write("") + _render_eps_chart(annual, "Annual EPS: Historical Actuals + Forward Estimates") + else: + st.info("No annual estimates available.") + + with tab_qtr: + if quarterly: + df = _build_estimates_table(quarterly) + st.dataframe(df, use_container_width=True, hide_index=True) + st.write("") + _render_eps_chart(quarterly, "Quarterly EPS: Historical Actuals + Forward Estimates") + else: + st.info("No quarterly estimates available.") |
