From 764cd69bfc2e5a0cf504c8d6e4f032d35edd9a4c Mon Sep 17 00:00:00 2001 From: Tyler Date: Thu, 14 May 2026 01:01:02 -0700 Subject: Completely refreshed Key Ratios, Historical Ratios, and DCF/Multiples tab. --- app.py | 25 +- components/financials.py | 6 +- components/insiders.py | 4 +- components/options.py | 8 +- components/overview.py | 4 +- components/top_movers.py | 2 +- components/valuation.py | 1226 +++++++++++++++++++++++++++++++++++++--------- services/fmp_service.py | 29 +- 8 files changed, 1051 insertions(+), 253 deletions(-) diff --git a/app.py b/app.py index 6085f1c..ff5ba35 100644 --- a/app.py +++ b/app.py @@ -34,14 +34,37 @@ st.markdown(""" --brass-bright: #DCC79E; --brass-deep: #8F7A50; --brass-ink: #17120A; + --oxford: #1F3D5C; + --oxford-light: #2E5A87; + --burgundy: #6E2A2E; + --burgundy-light:#8B3A3F; --positive: #4F8C5E; --positive-bg: #15241A; --negative: #B5494B; --negative-bg: #2A1517; --warning: #C49545; + --warning-bg: #2A1F0F; + --info: #4A78B5; + --info-bg: #11202E; + --focus-ring: rgba(194,170,122,0.55); --font-display: 'EB Garamond', Georgia, serif; --font-sans: 'IBM Plex Sans', 'Helvetica Neue', system-ui, sans-serif; --font-mono: 'IBM Plex Mono', 'SF Mono', Menlo, monospace; + --fs-12: 0.75rem; --fs-13: 0.8125rem; --fs-14: 0.875rem; + --fs-16: 1rem; --fs-18: 1.125rem; --fs-20: 1.25rem; + --fs-24: 1.5rem; --fs-30: 1.875rem; --fs-38: 2.375rem; + --fs-48: 3rem; --fs-64: 4rem; --fs-88: 5.5rem; + --w-light: 300; --w-regular: 400; --w-medium: 500; --w-semibold: 600; --w-bold: 700; + --lh-tight: 1.05; --lh-snug: 1.2; --lh-normal: 1.45; --lh-relaxed: 1.6; + --tr-tight: -0.02em; --tr-snug: -0.01em; --tr-normal: 0; --tr-wide: 0.04em; --tr-wider: 0.12em; + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; + --sp-6: 32px; --sp-7: 48px; --sp-8: 64px; --sp-9: 96px; --sp-10: 128px; + --r-0: 0; --r-1: 2px; --r-2: 4px; --r-3: 6px; --r-4: 8px; --r-full: 999px; + --shadow-1: 0 1px 0 rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3); + --shadow-2: 0 1px 0 rgba(0,0,0,.4), 0 4px 12px rgba(0,0,0,.45); + --shadow-3: 0 2px 0 rgba(0,0,0,.5), 0 12px 32px rgba(0,0,0,.55); + --shadow-inset: inset 0 1px 0 rgba(255,255,255,.04); + --shadow-brass: 0 0 0 1px rgba(194,170,122,.35), 0 6px 20px rgba(194,170,122,.18); } /* ── Base ───────────────────────────────────────────────────────────────── */ @@ -505,7 +528,7 @@ with st.sidebar: elif query: selected_symbol = query.upper() - submitted = st.form_submit_button("Open", use_container_width=True, type="primary") + submitted = st.form_submit_button("Open", width="stretch", type="primary") if submitted and selected_symbol: st.session_state["ticker"] = selected_symbol diff --git a/components/financials.py b/components/financials.py index 83e9a16..c01f8dc 100644 --- a/components/financials.py +++ b/components/financials.py @@ -287,13 +287,13 @@ def _render_grouped_statement(df: pd.DataFrame, groups: dict[str, list[str]], em with st.expander(section, expanded=(section == list(groups.keys())[0])): section_df = df.loc[rows] display, colors = _build_statement(section_df) - st.dataframe(_style(display, colors), use_container_width=True) + st.dataframe(_style(display, colors), width="stretch") remaining = [row for row in df.index if row not in grouped_rows] if remaining: with st.expander("Other Reported Line Items", expanded=False): display, colors = _build_statement(df.loc[remaining]) - st.dataframe(_style(display, colors), use_container_width=True) + st.dataframe(_style(display, colors), width="stretch") elif not grouped_rows: st.info(empty_msg) @@ -315,7 +315,7 @@ def _render_statement_block(title: str, df: pd.DataFrame, groups: dict[str, list _render_grouped_statement(df, groups, empty_msg) else: display, colors = _build_statement(df) - st.dataframe(_style(display, colors), use_container_width=True) + st.dataframe(_style(display, colors), width="stretch") st.download_button( "Download CSV", diff --git a/components/insiders.py b/components/insiders.py index bdb1818..1087061 100644 --- a/components/insiders.py +++ b/components/insiders.py @@ -97,7 +97,7 @@ def render_insiders(ticker: str): height=280, legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), ) - st.plotly_chart(fig, use_container_width=True) + st.plotly_chart(fig, width="stretch") st.divider() @@ -138,6 +138,6 @@ def render_insiders(ticker: str): st.dataframe( display.style.apply(_color_type, axis=1), - use_container_width=True, + width="stretch", hide_index=True, ) diff --git a/components/options.py b/components/options.py index 0acce31..b2c98c3 100644 --- a/components/options.py +++ b/components/options.py @@ -134,7 +134,7 @@ def render_options(ticker: str): legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), hovermode="x unified", ) - st.plotly_chart(fig_iv, use_container_width=True) + st.plotly_chart(fig_iv, width="stretch") # ── Open Interest by strike ─────────────────────────────────────────────── with chart_col2: @@ -172,7 +172,7 @@ def render_options(ticker: str): height=300, legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), ) - st.plotly_chart(fig_oi, use_container_width=True) + st.plotly_chart(fig_oi, width="stretch") # ── Raw chain table ─────────────────────────────────────────────────────── with st.expander("Full options chain"): @@ -187,7 +187,7 @@ def render_options(ticker: str): df_show["impliedVolatility"] = df_show["impliedVolatility"].apply( lambda v: f"{v*100:.1f}%" if pd.notna(v) else "—" ) - st.dataframe(df_show, use_container_width=True, hide_index=True) + st.dataframe(df_show, width="stretch", hide_index=True) with tab_puts: show_cols = [c for c in display_cols if c in puts.columns] @@ -197,4 +197,4 @@ def render_options(ticker: str): df_show["impliedVolatility"] = df_show["impliedVolatility"].apply( lambda v: f"{v*100:.1f}%" if pd.notna(v) else "—" ) - st.dataframe(df_show, use_container_width=True, hide_index=True) + st.dataframe(df_show, width="stretch", hide_index=True) diff --git a/components/overview.py b/components/overview.py index 9a0d162..433f6e5 100644 --- a/components/overview.py +++ b/components/overview.py @@ -329,7 +329,7 @@ def _render_relative_chart(ticker: str, info: dict, period: str): height=320, legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0), ) - st.plotly_chart(fig, use_container_width=True) + st.plotly_chart(fig, width="stretch") # ── Main render ────────────────────────────────────────────────────────────── @@ -442,4 +442,4 @@ def render_overview(ticker: str): hovermode="x unified", height=320, ) - st.plotly_chart(fig, use_container_width=True) + st.plotly_chart(fig, width="stretch") diff --git a/components/top_movers.py b/components/top_movers.py index db95592..d50eee7 100644 --- a/components/top_movers.py +++ b/components/top_movers.py @@ -143,7 +143,7 @@ def _render_mover_tab(screen: str, state_key: str): st.button( button_label, key=f"{state_key}_button", - use_container_width=True, + width="stretch", on_click=_toggle_mover_tab, args=(state_key,), ) diff --git a/components/valuation.py b/components/valuation.py index 6fc0171..010c831 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -1,5 +1,6 @@ """Valuation panel — key ratios, models, comparable companies, analyst targets, earnings history.""" import json +import numpy as np import pandas as pd import plotly.graph_objects as go import streamlit as st @@ -17,6 +18,8 @@ from services.data_service import ( get_recommendations_summary, get_earnings_history, get_next_earnings_date, + get_income_statement, + get_cash_flow, ) from services.fmp_service import ( get_key_ratios, @@ -161,154 +164,640 @@ def render_valuation(ticker: str): # ── Key Ratios ─────────────────────────────────────────────────────────────── +# CSS injected once per render for the Key Ratios design. +_KR_CSS = """""" + + +def _svg_spark(data: list, w: int = 96, h: int = 26, color: str = "var(--brass-bright)") -> str: + clean = [float(x) for x in data if x is not None and x == x] + if len(clean) < 2: + return "" + min_v, max_v = min(clean), max(clean) + span = (max_v - min_v) or 1 + dx = w / (len(clean) - 1) + pts = [(i * dx, h - ((v - min_v) / span) * (h - 4) - 2) for i, v in enumerate(clean)] + d = " ".join(f"{'M' if i == 0 else 'L'}{x:.2f} {y:.2f}" for i, (x, y) in enumerate(pts)) + lx, ly = pts[-1] + return ( + f'' + f'' + f'' + f'' + ) + + +def _peer_bar_html(value, p25, p50, p75, min_v, max_v) -> str: + def pct(v): + if min_v is None or max_v is None or max_v <= min_v: + return 50.0 + return max(0.0, min(100.0, (v - min_v) / (max_v - min_v) * 100)) + vp = pct(value) if value is not None else 50 + p25p, p75p, p50p = pct(p25), pct(p75), pct(p50) + return ( + f'
' + f'
' + f'
' + f'
' + f'
' + f'
' + ) + + +def _fmtv(v, kind: str) -> str: + if v is None: + return "—" + try: + fv = float(v) + if fv != fv: + return "—" + except (TypeError, ValueError): + return "—" + if kind == "%": + return f"{fv * 100:.1f}%" + if kind == "x": + return f"{fv:.1f}×" + if kind == "$B": + return f"${fv / 1e9:.1f}B" + if kind == "pp": + return f"{fv * 100:+.1f}pp" + return f"{fv:.2f}" + + +def _tone(delta_pct: float, invert: bool = False) -> str: + if abs(delta_pct) < 2: + return "flat" + better = delta_pct < 0 if invert else delta_pct > 0 + return "pos" if better else "neg" + + +def _compute_peer_bands(peer_ratio_rows: list[dict]) -> dict: + fields = [ + "peRatioTTM", "forwardPE", "enterpriseValueMultipleTTM", + "evToSalesTTM", "priceToBookRatioTTM", "priceToSalesRatioTTM", + "grossProfitMarginTTM", "operatingProfitMarginTTM", "netProfitMarginTTM", + "returnOnEquityTTM", "returnOnAssetsTTM", "returnOnInvestedCapitalTTM", + "currentRatioTTM", "quickRatioTTM", "debtToEquityRatioTTM", + "interestCoverageRatioTTM", "dividendYieldTTM", "dividendPayoutRatioTTM", + "revenueGrowthTTM", "earningsGrowthTTM", + ] + result = {} + for field in fields: + vals = [] + for row in peer_ratio_rows: + v = row.get(field) + if v is not None: + try: + fv = float(v) + if np.isfinite(fv) and fv > 0: + vals.append(fv) + except (TypeError, ValueError): + pass + if len(vals) >= 2: + arr = np.array(vals) + result[field] = { + "p25": float(np.percentile(arr, 25)), + "p50": float(np.percentile(arr, 50)), + "p75": float(np.percentile(arr, 75)), + "min": float(arr.min()), + "max": float(arr.max()), + "n": len(vals), + } + return result + + +def _compute_growth_ratios(ticker: str) -> dict: + result: dict = {} + try: + inc = get_income_statement(ticker) + cf = get_cash_flow(ticker) + + if inc is not None and not inc.empty and len(inc.columns) >= 2: + def _inc(label): + if label in inc.index: + v = inc.loc[label].dropna() + return v + return None + + rev = _inc("Total Revenue") + if rev is not None and len(rev) >= 2: + r0, r1 = float(rev.iloc[0]), float(rev.iloc[1]) + if r1 > 0: + result["revYoY"] = (r0 - r1) / r1 + if len(rev) >= 4: + r3 = float(rev.iloc[3]) + if r3 > 0 and r0 > 0: + result["rev3yrCAGR"] = (r0 / r3) ** (1 / 3) - 1 + + op_inc = _inc("Operating Income") + if op_inc is not None and len(op_inc) >= 2: + o0, o1 = float(op_inc.iloc[0]), float(op_inc.iloc[1]) + if abs(o1) > 0: + result["opIncYoY"] = (o0 - o1) / abs(o1) + + for lbl in ("Diluted Average Shares", "Diluted Common Shares Outstanding"): + shares = _inc(lbl) + if shares is not None and len(shares) >= 2: + s0, s1 = float(shares.iloc[0]), float(shares.iloc[1]) + if s1 > 0: + result["sharesYoY"] = (s0 - s1) / s1 + break + + for lbl in ("Diluted EPS", "Basic EPS"): + eps = _inc(lbl) + if eps is not None and len(eps) >= 2: + e0, e1 = float(eps.iloc[0]), float(eps.iloc[1]) + if abs(e1) > 0 and e1 > 0: + result["epsYoY"] = (e0 - e1) / e1 + break + + if cf is not None and not cf.empty: + fcf_s = None + if "Free Cash Flow" in cf.index: + fcf_s = cf.loc["Free Cash Flow"].dropna() + else: + try: + op = cf.loc["Operating Cash Flow"] + capex = cf.loc["Capital Expenditure"] + fcf_s = (op + capex).dropna() + except KeyError: + pass + if fcf_s is not None and len(fcf_s) >= 2: + f0, f1 = float(fcf_s.iloc[0]), float(fcf_s.iloc[1]) + if f1 > 0: + result["fcfYoY"] = (f0 - f1) / f1 + + mkt = get_market_cap_computed(ticker) + for lbl in ("Repurchase Of Capital Stock", "Common Stock Repurchased"): + if lbl in cf.index: + val = cf.loc[lbl].iloc[0] + if val is not None and pd.notna(val): + buybacks = abs(float(val)) + if mkt and mkt > 0 and buybacks > 0: + result["buybackYield"] = buybacks / mkt + break + except Exception: + pass + return result + + +def _build_hist_sparks(hist_rows: list[dict]) -> dict: + rows = list(reversed(hist_rows)) + def _ex(field): + return [r[field] for r in rows if field in r and r[field] is not None] + return { + "pe": _ex("peRatio"), + "pb": _ex("priceToBookRatio"), + "ps": _ex("priceToSalesRatio"), + "evEbt": _ex("enterpriseValueMultiple"), + "gross": _ex("grossProfitMargin"), + "op": _ex("operatingProfitMargin"), + "net": _ex("netProfitMargin"), + "roe": _ex("returnOnEquity"), + "roa": _ex("returnOnAssets"), + "de": _ex("debtEquityRatio"), + } + + def _render_ratios(ticker: str): - ratios = get_key_ratios(ticker) info = get_company_info(ticker) + ratios = get_key_ratios(ticker) if not ratios and not info: st.info("Ratio data unavailable.") return - def _normalized_label(label: str) -> str: - return " ".join(str(label).replace("/", " ").replace("-", " ").split()).strip().lower() + price = get_latest_price(ticker) + market_cap = get_market_cap_computed(ticker) + fcf_ttm = get_free_cash_flow_ttm(ticker) + hist_rows = get_historical_ratios(ticker, limit=7) + + # Peer set + peers_raw = get_peers(ticker) + if not peers_raw: + peers_raw = _suggest_peer_tickers(ticker, info or {}) + peers = [p for p in peers_raw[:8] if p.upper() != ticker.upper()] + peer_ratio_list = get_ratios_for_tickers(peers) if peers else [] + peer_bands = _compute_peer_bands(peer_ratio_list) + + growth = _compute_growth_ratios(ticker) + sparks = _build_hist_sparks(hist_rows) + + # Computed values + def _r(key): return ratios.get(key) if ratios else None + + pe = _r("peRatioTTM") or (info.get("trailingPE") if info else None) + pe_fwd = _r("forwardPE") or (info.get("forwardPE") if info else None) + ev_ebt = _r("enterpriseValueMultipleTTM") + ev_rev = _r("evToSalesTTM") + pb = _r("priceToBookRatioTTM") + ps = _r("priceToSalesRatioTTM") + fcf_yield_v = (fcf_ttm / market_cap) if fcf_ttm and market_cap and market_cap > 0 else None + p_fcf = (market_cap / fcf_ttm) if fcf_ttm and fcf_ttm > 0 and market_cap else None + gross_m = _r("grossProfitMarginTTM") + op_m = _r("operatingProfitMarginTTM") + net_m = _r("netProfitMarginTTM") + roe = _r("returnOnEquityTTM") + roa = _r("returnOnAssetsTTM") + roic = _r("returnOnInvestedCapitalTTM") + cur_r = _r("currentRatioTTM") + quick_r = _r("quickRatioTTM") + d_e = _r("debtToEquityRatioTTM") + coverage = _r("interestCoverageRatioTTM") + div_y = _r("dividendYieldTTM") + payout = _r("dividendPayoutRatioTTM") + ebitda = _r("ebitdaTTM") + cash_raw = None + net_debt_ebt = None + try: + bridge = get_balance_sheet_bridge_items(ticker) + cash_raw = bridge.get("cash_and_equivalents") + total_debt = bridge.get("total_debt") or 0 + if ebitda and ebitda > 0 and cash_raw is not None and total_debt is not None: + net_debt_ebt = (total_debt - cash_raw) / ebitda + if cash_raw and market_cap and market_cap > 0: + cash_mkt = cash_raw / market_cap + else: + cash_mkt = None + except Exception: + cash_mkt = None + net_debt_ebt = None + + # Price info + prev_close = info.get("previousClose") if info else None + if price and prev_close and prev_close > 0: + chg_pct = (price - prev_close) / prev_close * 100 + chg_str = f"{'▲' if chg_pct >= 0 else '▼'} {'+' if chg_pct >= 0 else ''}{chg_pct:.2f}%" + chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg" + else: + chg_str, chg_cls = "", "chg-pos" + + _XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} + raw_x = (info.get("exchange", "") if info else "") or "" + exchange = _XMAP.get(raw_x, raw_x) or "—" + co_name = (info.get("longName", ticker) if info else ticker) or ticker + sector = (info.get("sector", "—") if info else "—") or "—" + industry = (info.get("industry", "—") if info else "—") or "—" + n_peers = len(peers) + from datetime import date as _date + today_str = _date.today().strftime("%b %d, %Y") + + # ── Helper: render a row in the mini detail cards ────────────────────── + def _mini_row(lbl, v, kind, sector_v, spark_data, invert=False, good_low=False): + fv = _fmtv(v, kind) + sv = _fmtv(sector_v, kind) + if v is not None and sector_v is not None: + try: + fv_f, sv_f = float(v), float(sector_v) + if kind == "%": + # Show absolute percentage-point difference (design: "+4.1pp") + diff_pp = (fv_f - sv_f) * 100 + tone = "flat" if abs(diff_pp) < 0.3 else ("neg" if (invert or good_low) == (diff_pp > 0) else "pos") + mini_cls = f'{diff_pp:+.1f}pp' + else: + diff = (fv_f - sv_f) / abs(sv_f) * 100 + tone = _tone(diff, invert or good_low) + mini_cls = f'{diff:+.0f}%' + sector_html = f'{sv}{mini_cls}' + except Exception: + sector_html = f'{sv}' + else: + sector_html = f'{sv}' + spark_color = "var(--positive)" if not (invert or good_low) else "var(--warning)" + spark_svg = _svg_spark(spark_data, 86, 20, spark_color) if spark_data else "" + return ( + f'
' + f'{lbl}' + f'{fv}' + f'{sector_html}' + f'{spark_svg}' + f'
' + ) - def _display_value(key: str, fmt=fmt_ratio): - val = ratios.get(key) if ratios else None - return fmt(val) if val is not None else "—" + # ── Helper: build peer band section ──────────────────────────────────── + def _val_row(lbl, v, kind, field, five_avg, spark_data, invert=True): + fv = _fmtv(v, kind) + band = peer_bands.get(field, {}) + p25 = band.get("p25") + p50 = band.get("p50") + p75 = band.get("p75") + bmin = band.get("min") + bmax = band.get("max") + if v is not None and p50 is not None: + try: + diff = (float(v) - p50) / abs(p50) * 100 + tone = _tone(diff, invert) + d_str = f"{'+' if diff >= 0 else ''}{diff:.0f}%" + except Exception: + tone, d_str = "flat", "—" + else: + tone, d_str = "flat", "—" + if five_avg is not None and v is not None: + try: + d_avg = (float(v) - float(five_avg)) / abs(float(five_avg)) * 100 + avg_tone = _tone(d_avg, invert) + avg_html = ( + f'' + f'{_fmtv(five_avg, kind)}' + f'{d_avg:+.0f}%' + f'' + ) + except Exception: + avg_html = f'{_fmtv(five_avg, kind)}' + else: + avg_html = f'{_fmtv(five_avg, kind)}' + spark_color = "var(--negative)" if tone == "neg" else ("var(--positive)" if tone == "pos" else "var(--brass-bright)") + spark_svg = _svg_spark(spark_data, 108, 24, spark_color) if spark_data else "" + peer_bar = _peer_bar_html(v, p25, p50, p75, bmin, bmax) + peer_axis = "" + if p25 is not None: + peer_axis = ( + f'
' + f'{_fmtv(p25, kind)}' + f'{_fmtv(p50, kind)}' + f'{_fmtv(p75, kind)}' + f'
' + ) + return ( + f'
' + f'{lbl}' + f'{fv}' + f'{d_str}' + f'
{peer_bar}{peer_axis}
' + f'{avg_html}' + f'{spark_svg}' + f'
' + ) - def _company_context() -> dict: - return info or {} + # ── Snapshot KPIs ─────────────────────────────────────────────────────── + def _kpi(lbl, v, kind, field, invert=False): + fv = _fmtv(v, kind) + band = peer_bands.get(field, {}) + p50 = band.get("p50") + sect_str = _fmtv(p50, kind) if p50 is not None else "—" + if v is not None and p50 is not None: + try: + diff = (float(v) - p50) / abs(p50) * 100 + tone = _tone(diff, invert) + d_str = f"{'+' if diff >= 0 else ''}{diff:.0f}% vs peers" + except Exception: + tone, d_str = "flat", "—" + else: + tone, d_str = "flat", "—" + # Use historical data for sparkline when available + return tone, ( + f'
' + f'
{lbl}
' + f'{fv}' + f'
' + f'peers {sect_str}' + f'{d_str}' + f'
' + f'
' + ) - def _display_reasoned_metric(key: str, fmt=fmt_ratio) -> str: - val = ratios.get(key) if ratios else None - if val is not None: - return fmt(val) - - ctx = _company_context() - - if key == "peRatioTTM": - trailing_pe = ctx.get("trailingPE") - if trailing_pe is not None: - return fmt_ratio(trailing_pe) - if ratios and ratios.get("netProfitMarginTTM") is not None and ratios.get("netProfitMarginTTM") < 0: - return "N/M (neg. TTM earnings)" - trailing_eps = ctx.get("trailingEps") - if trailing_eps is not None: - try: - if float(trailing_eps) <= 0: - return "N/M (neg. TTM earnings)" - except (TypeError, ValueError): - pass - return "—" + def _kpi_spark(lbl, v, kind, field, spark_data, invert=False): + fv = _fmtv(v, kind) + band = peer_bands.get(field, {}) + p50 = band.get("p50") + sect_str = _fmtv(p50, kind) if p50 is not None else "—" + if v is not None and p50 is not None: + try: + diff = (float(v) - p50) / abs(p50) * 100 + tone = _tone(diff, invert) + d_str = f"{'+' if diff >= 0 else ''}{diff:.0f}% vs peers" + except Exception: + tone, d_str = "flat", "—" + else: + tone, d_str = "flat", "—" + spark_color = "var(--negative)" if tone == "neg" else ("var(--positive)" if tone == "pos" else "var(--brass-bright)") + spark_svg = _svg_spark(spark_data, 68, 22, spark_color) if spark_data else "" + return ( + f'
' + f'
{lbl}{spark_svg}
' + f'{fv}' + f'
' + f'peers {sect_str}' + f'{d_str}' + f'
' + f'
' + ) - if key == "priceToBookRatioTTM": - book_value = ctx.get("bookValue") - if book_value is not None: - try: - if float(book_value) <= 0: - return "N/M (neg. equity)" - except (TypeError, ValueError): - pass - return "—" + # Peer-median for snapshot section headings (approximated from bands) + snap_html = ( + _kpi_spark("P / E", pe, "x", "peRatioTTM", sparks.get("pe"), invert=True) + + _kpi_spark("EV / EBITDA", ev_ebt, "x", "enterpriseValueMultipleTTM", sparks.get("evEbt"), invert=True) + + _kpi_spark("EV / Revenue", ev_rev, "x", "evToSalesTTM", None, invert=True) + + _kpi_spark("P / Book", pb, "x", "priceToBookRatioTTM", sparks.get("pb"), invert=True) + + _kpi_spark("P / FCF", p_fcf, "x", "peRatioTTM", None, invert=True) + + _kpi_spark("FCF Yield", fcf_yield_v, "%", "dividendYieldTTM", None, invert=False) + ) - if key == "enterpriseValueMultipleTTM": - ebitda = ratios.get("ebitdaTTM") if ratios else None - if ebitda is not None: - try: - if float(ebitda) <= 0: - return "N/M (neg. EBITDA)" - except (TypeError, ValueError): - pass - return "—" + # ── Get 5-yr averages from historical rows ────────────────────────────── + def _hist_avg(field): + vals = [r.get(field) for r in hist_rows if r.get(field) is not None] + return float(np.mean(vals)) if vals else None + + pe_5avg = _hist_avg("peRatio") + pb_5avg = _hist_avg("priceToBookRatio") + ps_5avg = _hist_avg("priceToSalesRatio") + evEbt_5avg = _hist_avg("enterpriseValueMultiple") + gross_5avg = _hist_avg("grossProfitMargin") + op_5avg = _hist_avg("operatingProfitMargin") + net_5avg = _hist_avg("netProfitMargin") + roe_5avg = _hist_avg("returnOnEquity") + roa_5avg = _hist_avg("returnOnAssets") + de_5avg = _hist_avg("debtEquityRatio") + + # Peer medians for detail rows + def _pm(field): return peer_bands.get(field, {}).get("p50") + + # ── Assemble HTML ─────────────────────────────────────────────────────── + ctx_price = f'${price:,.2f}' if price else "" + ctx_chg = f'{chg_str}' if chg_str else "" + + val_rows_html = ( + _val_row("P / E · TTM", pe, "x", "peRatioTTM", pe_5avg, sparks.get("pe"), invert=True) + + _val_row("P / E · Forward", pe_fwd, "x", "forwardPE", None, None, invert=True) + + _val_row("EV / EBITDA", ev_ebt, "x", "enterpriseValueMultipleTTM", evEbt_5avg, sparks.get("evEbt"), invert=True) + + _val_row("EV / Revenue", ev_rev, "x", "evToSalesTTM", None, None, invert=True) + + _val_row("P / Book", pb, "x", "priceToBookRatioTTM", pb_5avg, sparks.get("pb"), invert=True) + + _val_row("P / Sales", ps, "x", "priceToSalesRatioTTM", ps_5avg, sparks.get("ps"), invert=True) + + _val_row("P / FCF", p_fcf, "x", "peRatioTTM", None, None, invert=True) + ) - if key == "dividendPayoutRatioTTM": - payout_ratio = ctx.get("payoutRatio") - if payout_ratio is not None: - try: - if float(payout_ratio) <= 0: - return "—" - except (TypeError, ValueError): - pass - if ratios and ratios.get("netProfitMarginTTM") is not None and ratios.get("netProfitMarginTTM") < 0: - return "N/M (neg. earnings)" - return "—" + prof_rows_html = ( + '
MetricSubjectPeers + ΔTrend
' + + _mini_row("Gross margin", gross_m, "%", _pm("grossProfitMarginTTM"), sparks.get("gross")) + + _mini_row("Operating margin", op_m, "%", _pm("operatingProfitMarginTTM"), sparks.get("op")) + + _mini_row("Net margin", net_m, "%", _pm("netProfitMarginTTM"), sparks.get("net")) + + _mini_row("Return on equity", roe, "%", _pm("returnOnEquityTTM"), sparks.get("roe")) + + _mini_row("Return on assets", roa, "%", _pm("returnOnAssetsTTM"), sparks.get("roa")) + + _mini_row("Return on invested capital", roic, "%", _pm("returnOnInvestedCapitalTTM"), None) + ) - if key == "returnOnEquityTTM": - book_value = ctx.get("bookValue") - if book_value is not None: - try: - if float(book_value) <= 0: - return "N/M (neg. equity)" - except (TypeError, ValueError): - pass - return "—" + growth_rows_html = ( + '
MetricSubjectPeers + ΔTrend
' + + _mini_row("Revenue · TTM YoY", growth.get("revYoY"), "%", _pm("revenueGrowthTTM"), None) + + _mini_row("Revenue · 3-yr CAGR", growth.get("rev3yrCAGR"), "%", None, None) + + _mini_row("EPS · TTM YoY", growth.get("epsYoY"), "%", _pm("earningsGrowthTTM"), None) + + _mini_row("FCF · TTM YoY", growth.get("fcfYoY"), "%", None, None) + + _mini_row("Operating income YoY",growth.get("opIncYoY"), "%", None, None) + + _mini_row("Diluted shares YoY", growth.get("sharesYoY"), "%", None, None, invert=True) + ) - if key == "debtToEquityRatioTTM": - book_value = ctx.get("bookValue") - if book_value is not None: - try: - if float(book_value) <= 0: - return "N/M (neg. equity)" - except (TypeError, ValueError): - pass - return "—" + health_rows_html = ( + '
MetricSubjectPeersTrend
' + + _mini_row("Net debt / EBITDA", net_debt_ebt, "x", _pm("debtToEquityRatioTTM"), None, good_low=True) + + _mini_row("Total debt / Equity", d_e, "x", _pm("debtToEquityRatioTTM"), sparks.get("de"), good_low=True) + + _mini_row("Interest coverage", coverage, "x", _pm("interestCoverageRatioTTM"), None) + + _mini_row("Current ratio", cur_r, "x", _pm("currentRatioTTM"), None) + + _mini_row("Quick ratio", quick_r, "x", _pm("quickRatioTTM"), None) + + _mini_row("Cash / Market cap", cash_mkt, "%", None, None) + ) - if key == "interestCoverageRatioTTM": - operating_margins = ctx.get("operatingMargins") - if operating_margins is not None: - try: - if float(operating_margins) <= 0: - return "N/M (neg. EBIT)" - except (TypeError, ValueError): - pass - return "—" + cash_rows_html = ( + '
MetricSubjectPeersTrend
' + + _mini_row("FCF yield", fcf_yield_v, "%", _pm("dividendYieldTTM"), None) + + _mini_row("Dividend yield", div_y, "%", _pm("dividendYieldTTM"), None) + + _mini_row("Payout ratio", payout, "%", _pm("dividendPayoutRatioTTM"), None, good_low=True) + + _mini_row("Buyback yield", growth.get("buybackYield"), "%", None, None) + ) - return "—" + body = ( + f'
' + f'{ticker.upper()}' + f'{co_name}' + f'Valuation · Key Ratios' + f'
{exchange}{ctx_price}{ctx_chg}
' + f'
' + f'
' + f'
' + f'
' + f'Snapshot' + f'
Where the lens sits — six headline ratios, scored against the peer set
' + f'

TTM ratios, peer medians from {n_peers} peers ({sector}). Sparklines show historical drift; the peer band on each row is the 25th–75th percentile of the peer set.

' + f'
' + f'
' + f'
Peer set{n_peers} names{industry[:28]}
' + f'
BasisTTMTrailing twelve months
' + f'
As of{today_str}Prices live · yfinance
' + f'
' + f'
' + f'
{snap_html}
' + f'
' + f'
I

Valuation multiples

Subject · Peer P25 / median / P75 · 5-yr drift
' + f'
RatioSubjectvs peersPeer 25 — 755-yr avg5-yr trend
' + f'{val_rows_html}' + f'
' + f'
' + f'
II

Profitability

Wider margins, higher returns on capital
{prof_rows_html}
' + f'
III

Growth · TTM

Topline & cash growth vs peers
{growth_rows_html}
' + f'
IV

Balance-sheet health

Leverage, liquidity, interest
{health_rows_html}
' + f'
V

Cash returns

Cash giveback to holders · yield
{cash_rows_html}
' + f'
' + f'
Ratios computed from yfinance financial statements, TTM basis. Peer bands from {n_peers} comparable names. Market data live.
' + f'
' + ) - def _dedupe_metrics(metrics: list[tuple[str, str]]) -> list[tuple[str, str]]: - deduped: list[tuple[str, str]] = [] - seen_labels: set[str] = set() - for label, val in metrics: - norm = _normalized_label(label) - if norm in seen_labels: - continue - seen_labels.add(norm) - deduped.append((label, val)) - return deduped - - rows = [ - ("Valuation", _dedupe_metrics([ - ("P/E (TTM)", _display_reasoned_metric("peRatioTTM")), - ("Forward P/E", _display_value("forwardPE")), - ("P/S (TTM)", _display_value("priceToSalesRatioTTM")), - ("P/B", _display_reasoned_metric("priceToBookRatioTTM")), - ("EV/EBITDA", _display_reasoned_metric("enterpriseValueMultipleTTM")), - ("EV/Revenue", _display_value("evToSalesTTM")), - ])), - ("Profitability", _dedupe_metrics([ - ("Gross Margin", _display_value("grossProfitMarginTTM", fmt=fmt_pct)), - ("Operating Margin", _display_value("operatingProfitMarginTTM", fmt=fmt_pct)), - ("Net Margin", _display_value("netProfitMarginTTM", fmt=fmt_pct)), - ("ROE", _display_reasoned_metric("returnOnEquityTTM", fmt=fmt_pct)), - ("ROA", _display_value("returnOnAssetsTTM", fmt=fmt_pct)), - ("ROIC", _display_value("returnOnInvestedCapitalTTM", fmt=fmt_pct)), - ])), - ("Leverage & Liquidity", _dedupe_metrics([ - ("Debt/Equity", _display_reasoned_metric("debtToEquityRatioTTM")), - ("Current Ratio", _display_value("currentRatioTTM")), - ("Quick Ratio", _display_value("quickRatioTTM")), - ("Interest Coverage", _display_reasoned_metric("interestCoverageRatioTTM")), - ("Dividend Yield", _display_value("dividendYieldTTM", fmt=fmt_pct)), - ("Payout Ratio", _display_reasoned_metric("dividendPayoutRatioTTM", fmt=fmt_pct)), - ])), - ] + doc = f""" + + + +{_KR_CSS} +{body}""" - 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("") + components.html(doc, height=2400, scrolling=True) # ── Models ─────────────────────────────────────────────────────────────────── @@ -895,6 +1384,7 @@ def _build_dcf_canvas_html( hist_growth_str = f"{result['growth_rate_used']*100:+.1f}%" net_debt_str = _fmt_b(net_debt_raw) shares_str = f"{ctx['shares']/1e9:.2f} B" + net_debt_label = f"Net debt{(' · ' + source_date) if source_date else ''}" html = f""" @@ -927,6 +1417,11 @@ def _build_dcf_canvas_html( .dcf-rail input[type=range]{{width:100%;-webkit-appearance:none;appearance:none;background:var(--ink-3);height:4px;border-radius:999px;cursor:pointer;outline:none}} .dcf-rail input[type=range]::-webkit-slider-thumb{{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;box-shadow:0 0 0 1px var(--brass-deep);cursor:pointer}} .dcf-rail input[type=range]::-moz-range-thumb{{width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;cursor:pointer;border:none}} +.rail-sl-hint{{display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:10px;color:var(--fg-4);margin-top:3px;letter-spacing:.02em}} +.rail-actions{{display:flex;flex-direction:column;gap:8px;margin-top:16px}} +.rail-btn{{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);background:var(--ink-2);border:1px solid var(--line-2);border-radius:3px;padding:7px 12px;cursor:pointer;text-align:center;transition:color .15s,border-color .15s;width:100%}} +.rail-btn:hover{{color:var(--fg-1);border-color:var(--line-3)}} +.rail-btn[disabled]{{opacity:.4;cursor:not-allowed;pointer-events:none}} @@ -944,6 +1439,7 @@ def _build_dcf_canvas_html( {wacc_pct:.2f}% +
4.0 aggressiveconservative 15.0
@@ -951,6 +1447,7 @@ def _build_dcf_canvas_html( {tg_pct:.1f}%
+
0.0 conservativeaggressive 5.0
@@ -958,6 +1455,7 @@ def _build_dcf_canvas_html( {yrs} yr
+
3 yr shortextended 10 yr
@@ -965,6 +1463,7 @@ def _build_dcf_canvas_html( {g_pct:.1f}%
+
-15 declinegrowth +20
@@ -974,8 +1473,13 @@ def _build_dcf_canvas_html(
From the filings
Base FCF (TTM){base_fcf_str}
FCF · 5-yr median{hist_growth_str}
-
Net debt{net_debt_str}
+
{net_debt_label}{net_debt_str}
Shares outstanding{shares_str}
+ +
+ + +
@@ -1086,6 +1590,18 @@ def _build_dcf_canvas_html( " + + "" + ) + components.html(doc, height=total_height, scrolling=True) # ── Forward Estimates ──────────────────────────────────────────────────────── @@ -2727,12 +3481,12 @@ def _render_forward_estimates(ticker: str): hovermode="x unified", legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), ) - st.plotly_chart(fig, use_container_width=True) + st.plotly_chart(fig, width="stretch") with tab_ann: if annual: df = _build_estimates_table(annual) - st.dataframe(df, use_container_width=True, hide_index=True) + st.dataframe(df, width="stretch", hide_index=True) st.write("") _render_eps_chart(annual, "Annual EPS: Historical Actuals + Forward Estimates") else: @@ -2741,7 +3495,7 @@ def _render_forward_estimates(ticker: str): with tab_qtr: if quarterly: df = _build_estimates_table(quarterly) - st.dataframe(df, use_container_width=True, hide_index=True) + st.dataframe(df, width="stretch", hide_index=True) st.write("") _render_eps_chart(quarterly, "Quarterly EPS: Historical Actuals + Forward Estimates") else: diff --git a/services/fmp_service.py b/services/fmp_service.py index 914c14d..c72524f 100644 --- a/services/fmp_service.py +++ b/services/fmp_service.py @@ -2,6 +2,7 @@ import os import requests import streamlit as st +from concurrent.futures import ThreadPoolExecutor, as_completed from dotenv import load_dotenv from services.data_service import get_company_info, get_historical_ratios_yfinance, compute_ttm_ratios @@ -76,6 +77,17 @@ def get_key_ratios(ticker: str) -> dict: if info.get("forwardPE") is not None: merged["forwardPE"] = info["forwardPE"] + # Growth fields — yfinance info provides these directly + for key, alias in ( + ("revenueGrowth", "revenueGrowthTTM"), + ("earningsGrowth", "earningsGrowthTTM"), + ): + val = info.get(key) + if val is not None: + try: + merged[alias] = float(val) + except (TypeError, ValueError): + pass # Fallback: dividends from info dict when cash-flow data is missing if merged.get("dividendYieldTTM") is None and info.get("dividendYield") is not None: merged["dividendYieldTTM"] = info["dividendYield"] @@ -116,13 +128,22 @@ def get_peers(ticker: str) -> list[str]: @st.cache_data(ttl=3600) def get_ratios_for_tickers(tickers: list[str]) -> list[dict]: - """Return merged TTM ratios/metrics rows for a list of tickers.""" - results = [] - for t in tickers: + """Return merged TTM ratios/metrics rows for a list of tickers, fetched in parallel.""" + def _fetch(t: str) -> dict | None: row = get_key_ratios(t) if row: + row = dict(row) row["symbol"] = t.upper() - results.append(row) + return row + return None + + results = [] + with ThreadPoolExecutor(max_workers=min(len(tickers), 8)) as pool: + futures = {pool.submit(_fetch, t): t for t in tickers} + for future in as_completed(futures): + result = future.result() + if result: + results.append(result) return results -- cgit v1.3-2-g0d8e