aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOpenclaw <openclaw@mail.tylerhoang.xyz>2026-03-29 13:21:39 -0700
committerOpenclaw <openclaw@mail.tylerhoang.xyz>2026-03-29 13:21:39 -0700
commit4fdcb4ce0f00bc8f62d50ba5d352dd2fe01cd7e7 (patch)
tree35dc4234751521d5a89a124f53eeaa827813be07
parentfc55820f5128f97e231de5388e59912e4a675782 (diff)
Add historical ratios, forward estimates, insider transactions, SEC filings
- services/fmp_service.py: add get_historical_ratios, get_historical_key_metrics, get_analyst_estimates, get_insider_transactions, get_sec_filings - components/valuation.py: add Historical Ratios and Forward Estimates subtabs - components/insiders.py: new — insider buy/sell summary, monthly chart, detail table - components/filings.py: new — SEC filings with type filter and direct links - app.py: wire in Insiders and Filings top-level tabs
-rw-r--r--app.py18
-rw-r--r--components/filings.py115
-rw-r--r--components/insiders.py161
-rw-r--r--components/valuation.py277
-rw-r--r--services/fmp_service.py43
5 files changed, 610 insertions, 4 deletions
diff --git a/app.py b/app.py
index a470213..b1638cd 100644
--- a/app.py
+++ b/app.py
@@ -59,6 +59,8 @@ 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.insiders import render_insiders
+from components.filings import render_filings
from components.news import render_news
from services.data_service import get_company_info, search_tickers
@@ -138,10 +140,12 @@ st.divider()
# ── Main Content ──────────────────────────────────────────────────────────────
-tab_overview, tab_financials, tab_valuation, tab_news = st.tabs([
+tab_overview, tab_financials, tab_valuation, tab_insiders, tab_filings, tab_news = st.tabs([
"📈 Overview",
"📊 Financials",
"💰 Valuation",
+ "👤 Insiders",
+ "📁 Filings",
"📰 News",
])
@@ -163,6 +167,18 @@ with tab_valuation:
except Exception as e:
st.error(f"Valuation failed to load: {e}")
+with tab_insiders:
+ try:
+ render_insiders(ticker)
+ except Exception as e:
+ st.error(f"Insider data failed to load: {e}")
+
+with tab_filings:
+ try:
+ render_filings(ticker)
+ except Exception as e:
+ st.error(f"Filings failed to load: {e}")
+
with tab_news:
try:
render_news(ticker)
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> &nbsp;·&nbsp; <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.")
diff --git a/services/fmp_service.py b/services/fmp_service.py
index 4dea211..0a3419d 100644
--- a/services/fmp_service.py
+++ b/services/fmp_service.py
@@ -117,3 +117,46 @@ def get_company_news(ticker: str, limit: int = 20) -> list[dict]:
if data and isinstance(data, list):
return data
return []
+
+
+@st.cache_data(ttl=86400)
+def get_historical_ratios(ticker: str, limit: int = 10) -> list[dict]:
+ """Annual historical valuation ratios (P/E, P/B, P/S, EV/EBITDA, etc.)."""
+ data = _get(LEGACY_BASE, f"/ratios/{ticker.upper()}", params={"limit": limit})
+ return data if isinstance(data, list) else []
+
+
+@st.cache_data(ttl=86400)
+def get_historical_key_metrics(ticker: str, limit: int = 10) -> list[dict]:
+ """Annual historical key metrics (ROE, ROA, margins, debt/equity, etc.)."""
+ data = _get(LEGACY_BASE, f"/key-metrics/{ticker.upper()}", params={"limit": limit})
+ return data if isinstance(data, list) else []
+
+
+@st.cache_data(ttl=3600)
+def get_analyst_estimates(ticker: str) -> dict:
+ """Return annual and quarterly forward analyst estimates."""
+ annual = _get(LEGACY_BASE, f"/analyst-estimates/{ticker.upper()}", params={"limit": 5})
+ quarterly = _get(
+ LEGACY_BASE,
+ f"/analyst-estimates/{ticker.upper()}",
+ params={"limit": 10, "period": "quarter"},
+ )
+ return {
+ "annual": annual if isinstance(annual, list) else [],
+ "quarterly": quarterly if isinstance(quarterly, list) else [],
+ }
+
+
+@st.cache_data(ttl=3600)
+def get_insider_transactions(ticker: str, limit: int = 50) -> list[dict]:
+ """Return recent insider buy/sell transactions."""
+ data = _get(LEGACY_BASE, "/insider-trading", params={"symbol": ticker.upper(), "limit": limit})
+ return data if isinstance(data, list) else []
+
+
+@st.cache_data(ttl=3600)
+def get_sec_filings(ticker: str, limit: int = 30) -> list[dict]:
+ """Return recent SEC filings (10-K, 10-Q, 8-K, etc.)."""
+ data = _get(LEGACY_BASE, f"/sec_filings/{ticker.upper()}", params={"limit": limit})
+ return data if isinstance(data, list) else []