aboutsummaryrefslogtreecommitdiff
path: root/components/valuation.py
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 /components/valuation.py
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
Diffstat (limited to 'components/valuation.py')
-rw-r--r--components/valuation.py277
1 files changed, 274 insertions, 3 deletions
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.")