diff options
Diffstat (limited to 'components/valuation.py')
| -rw-r--r-- | components/valuation.py | 277 |
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.") |
