From a1015045ea0693664faf3ce1fe010e52be8af103 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 15 May 2026 01:42:37 -0700 Subject: Redesigned valuation tabs --- components/valuation.py | 1170 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 937 insertions(+), 233 deletions(-) diff --git a/components/valuation.py b/components/valuation.py index 010c831..0758bdf 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -2758,101 +2758,517 @@ def _render_models(ticker: str): # ── Comps Table ────────────────────────────────────────────────────────────── -def _render_comps(ticker: str): - info = get_company_info(ticker) - auto_peers = get_peers(ticker) - suggested_peers = _suggest_peer_tickers(ticker, info) - - default_peer_string = ", ".join(auto_peers or suggested_peers) - manual_peer_string = st.text_input( - "Peer tickers", - value=default_peer_string, - help="Edit the comparable-company set manually. Comma-separated tickers.", - key=f"peer_input_{ticker.upper()}", - ) +_CC_CSS = """""" - if auto_peers: - st.caption("Using FMP-discovered peers.") - elif suggested_peers: - st.caption("Using Prism fallback peers based on sector/industry. Edit them if you want a tighter comp set.") - else: - st.caption("No automatic peer set found. Enter peer tickers manually to build a comps table.") - manual_peers = [p.strip().upper() for p in manual_peer_string.split(",") if p.strip()] - peer_list = [] - seen = {ticker.upper()} - for peer in manual_peers: - if peer not in seen: - peer_list.append(peer) - seen.add(peer) +def _render_comps(ticker: str): + import json as _json - all_tickers = [ticker.upper()] + peer_list[:9] + info = get_company_info(ticker) + auto_peers = get_peers(ticker) + if not auto_peers: + auto_peers = _suggest_peer_tickers(ticker, info or {}) + peer_syms = [p.upper() for p in auto_peers[:10]] + all_syms = [ticker.upper()] + peer_syms - with st.spinner("Loading comps..."): - ratios_list = get_ratios_for_tickers(all_tickers) + with st.spinner("Loading comps…"): + ratios_list = get_ratios_for_tickers(all_syms) if not ratios_list: - st.info("Could not load ratios for the selected peer companies.") + st.info("No ratio data available for the peer set.") return - display_cols = { - "symbol": "Ticker", - "peRatioTTM": "P/E", - "priceToSalesRatioTTM": "P/S", - "priceToBookRatioTTM": "P/B", - "enterpriseValueMultipleTTM": "EV/EBITDA", - "evToEBITDATTM": "EV/EBITDA", - "netProfitMarginTTM": "Net Margin", - "returnOnEquityTTM": "ROE", - "debtToEquityRatioTTM": "D/E", + ratios_map = {r["symbol"].upper(): r for r in ratios_list} + + COLS = [ + {"key": "pe", "lbl": "P/E · TTM", "short": "P/E", "kind": "x", "invert": True}, + {"key": "evEbt", "lbl": "EV/EBITDA", "short": "EV/EBITDA", "kind": "x", "invert": True}, + {"key": "evSales", "lbl": "EV/Sales", "short": "EV/Sales", "kind": "x", "invert": True}, + {"key": "pb", "lbl": "P/Book", "short": "P/B", "kind": "x", "invert": True}, + {"key": "revG", "lbl": "Rev YoY", "short": "Rev YoY", "kind": "%", "invert": False}, + {"key": "opM", "lbl": "Op margin", "short": "Op Mgn", "kind": "%", "invert": False}, + ] + + FIELD_MAP = { + "pe": ("peRatioTTM", 1.0), + "evEbt": ("enterpriseValueMultipleTTM", 1.0), + "evSales": ("evToSalesTTM", 1.0), + "pb": ("priceToBookRatioTTM", 1.0), + "revG": ("revenueGrowthTTM", 100.0), + "opM": ("operatingProfitMarginTTM", 100.0), } - df = pd.DataFrame(ratios_list) - if "enterpriseValueMultipleTTM" not in df.columns and "evToEBITDATTM" in df.columns: - df["enterpriseValueMultipleTTM"] = df["evToEBITDATTM"] - if "debtToEquityRatioTTM" not in df.columns and "debtEquityRatioTTM" in df.columns: - df["debtToEquityRatioTTM"] = df["debtEquityRatioTTM"] + _XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} - available = [c for c in ["symbol", "peRatioTTM", "priceToSalesRatioTTM", "priceToBookRatioTTM", "enterpriseValueMultipleTTM", "netProfitMarginTTM", "returnOnEquityTTM", "debtToEquityRatioTTM"] if c in df.columns] - df = df[available].rename(columns=display_cols) + peers = [] + for sym_i in all_syms: + r = ratios_map.get(sym_i, {}) + ci = get_company_info(sym_i) or {} + mcap_raw = ci.get("marketCap") or 0 + mcap_b = round(mcap_raw / 1e9, 2) if mcap_raw else None + row = { + "sym": sym_i, + "name": (ci.get("longName") or ci.get("shortName") or sym_i)[:40], + "mcap": mcap_b, + "subject": sym_i == ticker.upper(), + } + for col in COLS: + key = col["key"] + field, scale = FIELD_MAP[key] + v = r.get(field) + if v is not None: + try: + fv = float(v) * scale + if key in ("pe", "evEbt", "evSales", "pb") and (fv <= 0 or fv > 500): + row[key] = None + elif key in ("revG", "opM") and abs(fv) > 500: + row[key] = None + else: + row[key] = round(fv, 2) + except (TypeError, ValueError): + row[key] = None + else: + row[key] = None + peers.append(row) - def _format_comp_value(column: str, value): - if value is None: - return "—" - try: - v = float(value) - except (TypeError, ValueError): - return "—" + def _q(arr, q): + if not arr: + return None + s = sorted(arr) + pos = (len(s) - 1) * q + lo, hi = int(pos), min(int(pos) + 1, len(s) - 1) + return s[lo] if lo == hi else s[lo] + (s[hi] - s[lo]) * (pos - lo) + + stats = {} + for col in COLS: + key = col["key"] + vals = [p[key] for p in peers if p.get(key) is not None] + if not vals: + stats[key] = {"min": None, "max": None, "p25": None, "p50": None, "p75": None} + else: + stats[key] = { + "min": round(min(vals), 2), + "max": round(max(vals), 2), + "p25": round(_q(vals, 0.25), 2), + "p50": round(_q(vals, 0.50), 2), + "p75": round(_q(vals, 0.75), 2), + } - if column == "P/E": - return fmt_ratio(v) if v > 0 else "N/M (neg. earnings)" - if column == "P/B": - return fmt_ratio(v) if v > 0 else "N/M (neg. equity)" - if column == "EV/EBITDA": - return fmt_ratio(v) if v > 0 else "N/M (neg. EBITDA)" - if column == "D/E": - return fmt_ratio(v) if v >= 0 else "N/M (neg. equity)" - if column in {"Net Margin", "ROE"}: - return fmt_pct(v) - return fmt_ratio(v) if v > 0 else "—" - - for col in df.columns: - if col == "Ticker": + peer_median_row = {"sym": "—", "name": "Peer median", "mcap": None, "subject": False} + all_mcaps = [p["mcap"] for p in peers if p["mcap"] is not None] + peer_median_row["mcap"] = round(_q(all_mcaps, 0.5), 2) if all_mcaps else None + for col in COLS: + key = col["key"] + vals = [p[key] for p in peers if p.get(key) is not None] + peer_median_row[key] = round(_q(vals, 0.5), 2) if vals else None + + HERO_COLS = ["pe", "evEbt", "revG", "opM"] + subject_row = next((p for p in peers if p["subject"]), None) + + def _pctof(vals, v): + if not vals: + return 50 + return round(sum(1 for x in vals if x <= v) / len(vals) * 100) + + hero = [] + for col_key in HERO_COLS: + col = next(c for c in COLS if c["key"] == col_key) + st_data = stats[col_key] + if st_data["min"] is None or subject_row is None: + continue + subj_v = subject_row.get(col_key) + if subj_v is None: continue - df[col] = df[col].apply(lambda v, c=col: _format_comp_value(c, v)) + all_vals = [p[col_key] for p in peers if p.get(col_key) is not None] + if not all_vals: + continue + pct = _pctof(all_vals, subj_v) + median_v = st_data["p50"] + invert = col["invert"] + good = (pct < 50) if invert else (pct >= 50) + if invert: + readout = "Richer than peers" if pct >= 70 else ("In line with peers" if pct >= 30 else "Cheaper than peers") + else: + readout = "Outperforms peers" if pct >= 70 else ("In line with peers" if pct >= 30 else "Trails peers") + span = (st_data["max"] - st_data["min"]) or 1 + + def _pos(v_in, mn=st_data["min"], sp=span): + return round(max(0.0, min(100.0, (v_in - mn) / sp * 100)), 1) + + hero.append({ + "key": col_key, + "lbl": col["lbl"], + "kind": col["kind"], + "value": subj_v, + "median": median_v, + "pct": pct, + "good": good, + "readout": readout, + "subjPos": _pos(subj_v), + "peerPositions": [_pos(p[col_key]) for p in peers if not p["subject"] and p.get(col_key) is not None], + "p25Pos": _pos(st_data["p25"]), + "p75Pos": _pos(st_data["p75"]), + "medPos": _pos(st_data["p50"]), + "minV": st_data["min"], + "maxV": st_data["max"], + }) - def highlight_subject(row): - if row["Ticker"] == ticker.upper(): - return ["background-color: rgba(79,142,247,0.15)"] * len(row) - return [""] * len(row) + sym = ticker.upper() + name = (info.get("longName") or info.get("shortName") or sym) if info else sym + price = get_latest_price(ticker) + 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 = ("▲" if chg_pct >= 0 else "▼") + " " + ("+" if chg_pct >= 0 else "") + f"{chg_pct:.2f}%" + chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg" + else: + chg_str, chg_cls = "—", "" + raw_x = (info.get("exchange", "") if info else "") or "" + exchange = _XMAP.get(raw_x, raw_x) or "—" + price_str = f"${price:.2f}" if price else "—" - st.dataframe( - df.style.apply(highlight_subject, axis=1), - width="stretch", - hide_index=True, + n_peers = len(peers) - 1 + data_json = _json.dumps({ + "subject": sym, + "peers": peers, + "peerMedian": peer_median_row, + "cols": COLS, + "stats": stats, + "hero": hero, + "nPeers": n_peers, + }) + + total_height = 920 + n_peers * 54 + + ctx_html = ( + '
' + '' + sym + '' + '' + name + '' + 'Valuation · Comps' + '
' + '' + exchange + '' + '' + price_str + '' + '' + chg_str + '' + '
' + ) + + lede_html = ( + '
' + '
' + 'Peer set' + '

' + str(n_peers) + ' names, one table — read across to see where ' + sym + ' sits

' + '

Peers sourced from FMP stock-peers or Prism sector fallback. ' + 'Subject pinned at top, followed by the peer median; the rest sort by any column. ' + 'Every numeric cell shows the value plus a track of where it sits in the column distribution.

' + '
' + '
' + '
Peer set' + '' + str(n_peers) + ' names' + 'Sector · similar market cap
' + '
Tagging' + 'Auto-matched' + 'FMP peers · 6h cache
' + '
Period' + 'TTM' + 'Prices live · ratios T-1
' + '
' + '
' + ) + + hero_html = '
' + + table_html = ( + '
' + '
' + '
Side by side' + '

Peer comparison · TTM ratios

' + 'Click a column header to sort · dot in each cell shows column percentile' + '
' + '
' + '
' + ) + + foot_html = ( + '
' + 'Peer set sourced from FMP stock-peers or Prism sector fallback. ' + 'Market cap from yfinance. Ratios self-computed from TTM statements. ' + 'Distribution dot shows position within min↔max of the peer set.' + '
' + ) + + body = ctx_html + '
' + lede_html + hero_html + table_html + foot_html + '
' + + js = ( + "const DATA=" + data_json + ";\n" + "var sortKey='mcap',sortDir='desc';\n" + "function fmtV(v,kind){\n" + " if(v===null||v===undefined)return'—';\n" + " if(kind==='x')return v.toFixed(1)+'×';\n" + " if(kind==='%')return v.toFixed(1)+'%';\n" + " return v.toFixed(2);\n" + "}\n" + "function fmtMcap(v){\n" + " if(v===null||v===undefined)return'—';\n" + " if(v>=1000)return'$'+(v/1000).toFixed(2)+'T';\n" + " return'$'+v.toFixed(1)+'B';\n" + "}\n" + "function renderHero(){\n" + " var h=DATA.hero,html='';\n" + " for(var i=0;i';\n" + " }\n" + " html+='
';\n" + " html+='
'+c.lbl+'';\n" + " html+='P'+c.pct+'
';\n" + " html+='
';\n" + " html+='
'+DATA.subject+'';\n" + " html+=''+fmtV(c.value,c.kind)+'
';\n" + " html+='
Peer median';\n" + " html+=''+fmtV(c.median,c.kind)+'
';\n" + " html+='
';\n" + " html+='
';\n" + " html+='
';\n" + " html+='
';\n" + " html+=dots;\n" + " html+='
';\n" + " html+='
'+fmtV(c.minV,c.kind)+'';\n" + " html+=''+fmtV(c.maxV,c.kind)+'
';\n" + " html+=''+c.readout+'
';\n" + " }\n" + " document.getElementById('cmp-hero').innerHTML=html;\n" + "}\n" + "function distCell(v,colKey,hl){\n" + " var st=DATA.stats[colKey],col=null;\n" + " for(var i=0;i';\n" + " }\n" + " var span=(st.max-st.min)||1;\n" + " var pct=Math.max(0,Math.min(100,((v-st.min)/span)*100));\n" + " var tone=col.invert?(v>st.p50?'neg':'pos'):(v>st.p50?'pos':'neg');\n" + " var dotCls=hl?'subject':tone;\n" + " return'
'"\ + "+''+fmtV(v,col.kind)+''"\ + "+'
'"\ + "+'
'"\ + "+'
';\n" + "}\n" + "function renderTable(){\n" + " var peers=DATA.peers,pm=DATA.peerMedian,cols=DATA.cols;\n" + " var subject=null,others=[];\n" + " for(var i=0;i';\n" + " hdr+='Ticker';\n" + " hdr+='Company';\n" + " hdr+='Mkt cap'+(sortKey==='mcap'?''+arr+'':'')+'';\n" + " for(var i=0;i';\n" + " hdr+=c.short+(sortKey===c.key?''+arr+'':'')+'';\n" + " }\n" + " hdr+='';\n" + " function buildRow(p,cls){\n" + " var r='
';\n" + " if(p.subject)r+=''+p.sym+' subject';\n" + " else r+=''+p.sym+'';\n" + " r+=''+p.name;\n" + " if(cls===' median')r+=' '+DATA.nPeers+' names';\n" + " r+='';\n" + " var mcCls=(cls===' median')?' dim':'';\n" + " r+=''+fmtMcap(p.mcap)+'';\n" + " if(cls===' median'){\n" + " for(var i=0;i
';\n" + " }\n" + " } else {\n" + " var hl=!!p.subject;\n" + " for(var i=0;i" + "" + "" + "" + + _KR_CSS + _CC_CSS + + "" + + body + + "" + + "" ) + components.html(doc, height=total_height, scrolling=True) + +# ── Analyst Targets CSS ────────────────────────────────────────────────────── +_AT_CSS = """ +body { background: #0B0E13; color: #C7C0AE; font-family: 'IBM Plex Sans', sans-serif; font-size: 13px; padding: 20px; margin: 0; box-sizing: border-box; } +*, *::before, *::after { box-sizing: border-box; } +.num { font-family: 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; } + +.at-lede { + display: grid; + grid-template-columns: 1.6fr 1fr; + gap: 24px; + align-items: stretch; + background: #11151C; + border: 1px solid #232934; + border-radius: 6px; + padding: 24px; + margin-bottom: 16px; +} +.at-lede .left { display: flex; flex-direction: column; gap: 10px; } +.eyebrow-lbl { font-family: 'IBM Plex Sans', sans-serif; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: #8E8676; font-weight: 600; } +.ttl { font-family: 'EB Garamond', Georgia, serif; font-size: 26px; font-weight: 500; letter-spacing: -0.01em; color: #F2ECDC; line-height: 1.2; max-width: 38ch; } +.sub { font-family: 'IBM Plex Sans', sans-serif; font-size: 13px; color: #8E8676; line-height: 1.55; max-width: 64ch; } +.at-lede .right { display: flex; flex-direction: column; gap: 8px; } +.at-source { background: #181D26; border: 1px solid #232934; border-radius: 4px; padding: 10px 12px; display: flex; flex-direction: column; gap: 2px; } +.at-source .lbl { font-family: 'IBM Plex Sans', sans-serif; font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #5E5849; font-weight: 600; } +.at-source .v { font-family: 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; font-size: 14px; color: #F2ECDC; font-weight: 500; } +.at-source .cap { font-family: 'IBM Plex Mono', monospace; font-size: 10px; color: #5E5849; } + +.at-card { background: #11151C; border: 1px solid #232934; border-radius: 6px; padding: 24px; margin-bottom: 16px; } +.at-card-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20px; } +.at-card-head .left-group { display: flex; align-items: baseline; gap: 8px; } +.at-card-head .roman { font-family: 'EB Garamond', Georgia, serif; font-style: italic; font-size: 20px; color: #C2AA7A; font-weight: 400; } +.at-card-head h3 { font-family: 'EB Garamond', Georgia, serif; font-size: 20px; font-weight: 500; letter-spacing: -0.01em; color: #F2ECDC; margin: 0; } +.at-card-head .hint { font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: #5E5849; } + +.readout { font-family: 'EB Garamond', Georgia, serif; font-style: italic; font-size: 14px; color: #C7C0AE; margin-top: 14px; padding-top: 10px; border-top: 1px solid #232934; line-height: 1.5; } + +.stat-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-top: 16px; } +.stat-card { background: #181D26; border: 1px solid #232934; border-radius: 4px; padding: 12px; } +.stat-card .lbl { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #5E5849; margin-bottom: 4px; font-weight: 500; } +.stat-card .val { display: block; font-family: 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; font-size: 20px; font-weight: 500; color: #DCC79E; line-height: 1.1; } +.stat-card .delta { font-family: 'IBM Plex Mono', monospace; font-size: 11px; } +.pos { color: #4F8C5E; } +.neg { color: #B5494B; } + +.rec-stacked { height: 24px; border-radius: 4px; overflow: hidden; display: flex; margin: 14px 0 12px; } +.rec-seg { height: 100%; } +.rec-legend { display: flex; gap: 20px; flex-wrap: wrap; } +.rec-legend-item { display: flex; align-items: center; gap: 6px; } +.rec-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } +.rec-legend .name { font-size: 11px; color: #8E8676; } +.rec-legend .count { font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: #C7C0AE; } +.rec-legend .pct { font-family: 'IBM Plex Mono', monospace; font-size: 10px; color: #5E5849; } +""" + # ── Analyst Targets ────────────────────────────────────────────────────────── def _render_analyst_targets(ticker: str): @@ -2860,68 +3276,184 @@ def _render_analyst_targets(ticker: str): recs = get_recommendations_summary(ticker) if not targets and (recs is None or recs.empty): - st.info("Analyst data unavailable for this ticker.") + st.info("Analyst data unavailable.") return - if targets: - st.markdown("**Analyst Price Targets**") - current = targets.get("current") - mean_t = targets.get("mean") - - t1, t2, t3, t4, t5 = st.columns(5) - t1.metric("Low", fmt_currency(targets.get("low"))) - t2.metric("Mean", fmt_currency(mean_t)) - t3.metric("Median", fmt_currency(targets.get("median"))) - t4.metric("High", fmt_currency(targets.get("high"))) - if current and mean_t: - upside = (mean_t - current) / current - t5.metric("Upside to Mean", f"{upside * 100:+.1f}%", delta=f"{upside * 100:+.1f}%") - else: - t5.metric("Current Price", fmt_currency(current)) + # ── Extract and normalize targets ── + current = targets.get("current") or 0 + low = targets.get("low") or 0 + mean_t = targets.get("mean") or 0 + median_t = targets.get("median") or 0 + high = targets.get("high") or 0 - st.write("") + # Upside calculation + upside = (mean_t - current) / current if current > 0 and mean_t else None + # ── SVG range bar positioning (0–800 scale) ── + span = high - low if high > low else 1 + + def _pct(v): + return (v - low) / span if span > 0 else 0 + + px_current = _pct(current) * 800 + px_mean = _pct(mean_t) * 800 + + # Clamp to avoid edge clipping (8–792 px range) + px_current = max(8, min(792, px_current)) + px_mean = max(8, min(792, px_mean)) + + # ── Extract recommendations ── + counts = {"Strong Buy": 0, "Buy": 0, "Hold": 0, "Sell": 0, "Strong Sell": 0} if recs is not None and not recs.empty: - st.markdown("**Analyst Recommendations (Current Month)**") - - current_row = recs[recs["period"] == "0m"] if "period" in recs.columns else pd.DataFrame() - if current_row.empty: - current_row = recs.iloc[[0]] - - row = current_row.iloc[0] - counts = { - "Strong Buy": int(row.get("strongBuy", 0)), - "Buy": int(row.get("buy", 0)), - "Hold": int(row.get("hold", 0)), - "Sell": int(row.get("sell", 0)), - "Strong Sell": int(row.get("strongSell", 0)), - } - total = sum(counts.values()) - - cols = st.columns(5) - for col, (label, count) in zip(cols, counts.items()): - pct = f"{count / total * 100:.0f}%" if total > 0 else "—" - col.metric(label, str(count), delta=pct, delta_color="off") - - st.write("") - - colors = ["#4F8C5E", "#4F8C5E", "#C49545", "#8F7A50", "#B5494B"] - fig = go.Figure(go.Bar( - x=list(counts.keys()), - y=list(counts.values()), - marker_color=colors, - text=list(counts.values()), - textposition="outside", - )) - fig.update_layout( - title="Analyst Recommendation Distribution", - yaxis_title="# Analysts", - 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, + if "period" in recs.columns: + row = recs[recs["period"] == "0m"] + if not row.empty: + row = row.iloc[0] + else: + row = recs.iloc[0] + else: + row = recs.iloc[0] + + counts["Strong Buy"] = int(row.get("strongBuy", 0)) + counts["Buy"] = int(row.get("buy", 0)) + counts["Hold"] = int(row.get("hold", 0)) + counts["Sell"] = int(row.get("sell", 0)) + counts["Strong Sell"] = int(row.get("strongSell", 0)) + + total = sum(counts.values()) + + # ── Narrative readouts ── + upside_str = f"{upside*100:+.1f}%" if upside is not None else "—" + upside_cls = "pos" if (upside or 0) > 0 else "neg" + + if upside and upside > 0.20: + readout = f"Consensus sees significant upside — analysts expect {upside*100:.0f}% appreciation from current levels." + elif upside and upside > 0.05: + readout = f"Moderate upside in view — the mean target implies {upside*100:.0f}% from current price." + elif upside and upside > 0: + readout = f"Limited upside priced in — analysts see {upside*100:.0f}% appreciation from here." + elif upside and upside < 0: + readout = f"Targets trail price — mean consensus implies {abs(upside)*100:.0f}% downside from current." + else: + readout = "Analyst consensus on price targets." + + strong_bullish = counts["Strong Buy"] + counts["Buy"] + bearish = counts["Sell"] + counts["Strong Sell"] + if total > 0: + bull_pct = strong_bullish / total + if bull_pct >= 0.70: + consensus_readout = f"Strong bullish consensus — {strong_bullish} of {total} analysts rate this a Buy or better." + elif bull_pct >= 0.40: + consensus_readout = f"Mixed but leaning bullish — {strong_bullish} analysts bullish against {total - strong_bullish} neutral or bearish." + elif bearish / total >= 0.30: + consensus_readout = f"Elevated skepticism — {bearish} of {total} analysts carry a sell rating." + else: + consensus_readout = f"Cautious stance — analysts predominantly hold with limited conviction on direction." + else: + consensus_readout = "Insufficient coverage to assess consensus." + + # ── Build SVG range bar ── + svg_fill = "" + if mean_t > current: + fill_pct = abs(px_mean - px_current) + svg_fill = f'' + elif mean_t < current and current > 0: + fill_pct = abs(px_mean - px_current) + svg_fill = f'' + + svg_html = f""" + + + {svg_fill} + + + {fmt_currency(low)} + {fmt_currency(high)} + + Current {fmt_currency(current)} + + + Mean {fmt_currency(mean_t)} + """ + + # ── Build stat cards HTML ── + def _stat(lbl, val_str, delta=None, delta_cls=""): + delta_html = f'{delta}' if delta else '' + return f'
{lbl}{val_str}{delta_html}
' + + stat_row = ( + _stat("Low", fmt_currency(low)) + + _stat("Mean", fmt_currency(mean_t), upside_str, upside_cls) + + _stat("Median", fmt_currency(median_t)) + + _stat("High", fmt_currency(high)) + + _stat("Upside to mean", upside_str if upside is not None else "—", delta_cls=upside_cls) + ) + stat_html = f'
{stat_row}
' + + # ── Build recommendation stacked bar + legend ── + rec_colors = {"Strong Buy": "#2E5A35", "Buy": "#4F8C5E", "Hold": "#8F7A50", "Sell": "#8B3A3F", "Strong Sell": "#6E2A2E"} + bar_segs = "" + legend_items = "" + for label, count in counts.items(): + if total > 0 and count > 0: + pct = count / total * 100 + bar_segs += f'
' + pct_str = f"({count/total*100:.0f}%)" if total > 0 else "(0%)" + legend_items += ( + f'
' + f'
' + f'{label}' + f'{count}' + f'{pct_str}' + f'
' ) - st.plotly_chart(fig, width="stretch") + + # ── Build full HTML document ── + doc = f""" + + + + + + +
+
+ Analyst coverage +
Where the street sets its sights — {total} analysts, one consensus
+

Price targets and recommendation breakdown as of the current reporting period. The range bar shows where current price sits relative to the analyst target spectrum.

+
+
+
Coverage{total} analystscurrent month
+
Mean target{fmt_currency(mean_t)}vs {fmt_currency(current)} current
+
Upside / downside{upside_str}to mean target
+
+
+ + +
+
+
I

Price target range

+ Low · Current price · Mean target · High +
+ {svg_html} + {stat_html} +
{readout}
+
+ + +
+
+
II

Recommendation breakdown

+ {total} analysts · current month +
+
{bar_segs}
+
{legend_items}
+
{consensus_readout}
+
+ +""" + + components.html(doc, height=700, scrolling=False) # ── Earnings History ────────────────────────────────────────────────────────── @@ -3378,6 +3910,54 @@ def _render_historical_ratios(ticker: str): # ── Forward Estimates ──────────────────────────────────────────────────────── +_FE_CSS = """ +body { background: #0B0E13; color: #C7C0AE; font-family: 'IBM Plex Sans', sans-serif; font-size: 13px; padding: 20px; margin: 0; } +*, *::before, *::after { box-sizing: border-box; } +.num { font-family: 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; } +.fe-lede { display: grid; grid-template-columns: 1.6fr 1fr; gap: 24px; align-items: stretch; background: #11151C; border: 1px solid #232934; border-radius: 6px; padding: 24px; margin-bottom: 16px; } +.fe-lede .left { display: flex; flex-direction: column; gap: 10px; } +.fe-lede .right { display: flex; flex-direction: column; gap: 8px; } +.eyebrow-lbl { font-family: 'IBM Plex Sans', sans-serif; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: #8E8676; font-weight: 600; } +.ttl { font-family: 'EB Garamond', Georgia, serif; font-size: 26px; font-weight: 500; letter-spacing: -0.01em; color: #F2ECDC; line-height: 1.2; max-width: 38ch; } +.sub { font-family: 'IBM Plex Sans', sans-serif; font-size: 13px; color: #8E8676; line-height: 1.55; max-width: 64ch; } +.fe-source { background: #181D26; border: 1px solid #232934; border-radius: 4px; padding: 10px 12px; display: flex; flex-direction: column; gap: 2px; } +.fe-source .lbl { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #5E5849; font-weight: 600; } +.fe-source .v { font-family: 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; font-size: 14px; color: #F2ECDC; font-weight: 500; } +.fe-source .cap { font-family: 'IBM Plex Mono', monospace; font-size: 10px; color: #5E5849; } +.tab-row { display: flex; gap: 8px; margin-bottom: 20px; } +.tab-pill { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; padding: 4px 12px; border-radius: 999px; cursor: pointer; border: none; font-family: 'IBM Plex Sans', sans-serif; } +.tab-pill.active { background: #C2AA7A; color: #17120A; font-weight: 600; } +.tab-pill.inactive { background: #181D26; border: 1px solid #2E3645; color: #8E8676; } +.fe-card { background: #11151C; border: 1px solid #232934; border-radius: 6px; padding: 24px; margin-bottom: 16px; } +.fe-card-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 16px; } +.fe-card-head .left-group { display: flex; align-items: baseline; gap: 8px; } +.roman { font-family: 'EB Garamond', Georgia, serif; font-style: italic; font-size: 20px; color: #C2AA7A; font-weight: 400; } +h3 { font-family: 'EB Garamond', Georgia, serif; font-size: 20px; font-weight: 500; letter-spacing: -0.01em; color: #F2ECDC; margin: 0; } +.hint { font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: #5E5849; } +#rev-chart { width: 100%; height: 280px; } +.chart-legend { display: flex; gap: 20px; margin-top: 10px; } +.legend-item { display: flex; align-items: center; gap: 7px; font-family: 'IBM Plex Sans', sans-serif; font-size: 11px; color: #8E8676; } +.legend-swatch { width: 18px; height: 2px; flex-shrink: 0; } +.legend-swatch.solid { background: #C2AA7A; } +.legend-swatch.dashed { background: transparent; border-bottom: 2px dashed #C2AA7A; } +.legend-swatch.band { background: rgba(31,61,92,0.7); height: 8px; border-radius: 2px; } +.readout { font-family: 'EB Garamond', Georgia, serif; font-style: italic; font-size: 14px; color: #C7C0AE; margin-top: 14px; padding-top: 10px; border-top: 1px solid #232934; line-height: 1.5; } +.fe-table-card { background: #11151C; border: 1px solid #232934; border-radius: 6px; overflow: hidden; } +.fe-table-head-section { padding: 24px 24px 0; } +table { width: 100%; border-collapse: collapse; } +thead th { background: #181D26; font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #5E5849; padding: 8px 16px; text-align: right; font-weight: 500; } +thead th:first-child { text-align: left; } +tbody tr { border-bottom: 1px solid #232934; height: 44px; } +tbody tr:hover { background: rgba(194,170,122,0.04); } +td { padding: 0 16px; vertical-align: middle; } +td:first-child { text-align: left; font-weight: 500; color: #C7C0AE; } +.range-mini { display: inline-block; width: 72px; height: 20px; position: relative; vertical-align: middle; } +.range-mini-track { position: absolute; width: 72px; height: 4px; top: 8px; background: #222934; border-radius: 2px; } +.range-mini-band { position: absolute; height: 4px; top: 8px; background: rgba(31,61,92,0.6); border-radius: 2px; } +.range-mini-dot { position: absolute; width: 7px; height: 7px; top: 6px; border-radius: 50%; background: #C2AA7A; border: 1.5px solid #0B0E13; } +.info-banner { background: #181D26; border-left: 3px solid #1F3D5C; color: #8E8676; font-size: 12px; padding: 12px 16px; border-radius: 0 4px 4px 0; } +""" + def _render_forward_estimates(ticker: str): with st.spinner("Loading forward estimates…"): estimates = get_analyst_estimates(ticker) @@ -3389,16 +3969,10 @@ def _render_forward_estimates(ticker: str): st.info("Forward estimates unavailable. Requires FMP API key.") return - info = get_company_info(ticker) - current_price = get_latest_price(ticker) - - tab_ann, tab_qtr = st.tabs(["Annual", "Quarterly"]) - - def _build_estimates_table(rows: list[dict]) -> pd.DataFrame: - table = [] + def _parse_est_rows(rows: list[dict]) -> list[dict]: + """Parse raw FMP estimate rows into normalized structure.""" + parsed = [] for row in sorted(rows, key=lambda r: str(r.get("date", ""))): - date = str(row.get("date", ""))[:7] - # FMP stable endpoint uses revenueAvg / epsAvg (no "estimated" prefix) rev_avg = row.get("revenueAvg") or row.get("estimatedRevenueAvg") rev_lo = row.get("revenueLow") or row.get("estimatedRevenueLow") rev_hi = row.get("revenueHigh") or row.get("estimatedRevenueHigh") @@ -3406,97 +3980,227 @@ def _render_forward_estimates(ticker: str): eps_lo = row.get("epsLow") or row.get("estimatedEpsLow") eps_hi = row.get("epsHigh") or row.get("estimatedEpsHigh") ebitda_avg = row.get("ebitdaAvg") or row.get("estimatedEbitdaAvg") - num_analysts = row.get("numAnalystsRevenue") or row.get("numAnalystsEps") or 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 "—", + n_analysts = row.get("numAnalystsRevenue") or row.get("numAnalystsEps") or row.get("numberAnalystEstimatedRevenue") or row.get("numberAnalysts") + parsed.append({ + "date": str(row.get("date", "")), + "rev_avg": rev_avg, + "rev_lo": rev_lo, + "rev_hi": rev_hi, + "eps_avg": eps_avg, + "eps_lo": eps_lo, + "eps_hi": eps_hi, + "ebitda_avg": ebitda_avg, + "n_analysts": int(n_analysts) if n_analysts else 0, }) - 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("epsAvg") or row.get("estimatedEpsAvg") - eps_lo = row.get("epsLow") or row.get("estimatedEpsLow") - eps_hi = row.get("epsHigh") or 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="#C2AA7A", width=2), - )) - - if fwd_dates: - # Low/high band - fwd_lo = [float(r.get("epsLow") or r.get("estimatedEpsLow")) for r in sorted(rows, key=lambda r: str(r.get("date", ""))) - if (r.get("epsLow") or r.get("estimatedEpsLow")) is not None] - fwd_hi = [float(r.get("epsHigh") or r.get("estimatedEpsHigh")) for r in sorted(rows, key=lambda r: str(r.get("date", ""))) - if (r.get("epsHigh") or 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="#C49545", 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, width="stretch") - - with tab_ann: - if annual: - df = _build_estimates_table(annual) - st.dataframe(df, width="stretch", 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, width="stretch", hide_index=True) - st.write("") - _render_eps_chart(quarterly, "Quarterly EPS: Historical Actuals + Forward Estimates") + return parsed + + def _range_bar(lo, avg, hi, lo_min, hi_max) -> str: + """Render a mini range bar with low–high band and average marker.""" + if not lo or not hi or not avg: + return '' + lo_f, avg_f, hi_f = float(lo), float(avg), float(hi) + lo_min_f, hi_max_f = float(lo_min), float(hi_max) + range_span = hi_max_f - lo_min_f + if range_span <= 0: + return '' + lo_pct = ((lo_f - lo_min_f) / range_span) * 100 + hi_pct = ((hi_f - lo_min_f) / range_span) * 100 + avg_pct = ((avg_f - lo_min_f) / range_span) * 100 + return f'
' + + def _build_est_table_html(rows: list[dict], is_annual: bool = True) -> str: + """Build HTML table body for estimates.""" + if not rows: + return "" + all_rev_lo = [r.get("rev_lo") for r in rows if r.get("rev_lo")] + all_rev_hi = [r.get("rev_hi") for r in rows if r.get("rev_hi")] + all_eps_lo = [r.get("eps_lo") for r in rows if r.get("eps_lo")] + all_eps_hi = [r.get("eps_hi") for r in rows if r.get("eps_hi")] + rev_lo_min = min(all_rev_lo) if all_rev_lo else None + rev_hi_max = max(all_rev_hi) if all_rev_hi else None + eps_lo_min = min(all_eps_lo) if all_eps_lo else None + eps_hi_max = max(all_eps_hi) if all_eps_hi else None + tbody = [] + for row in rows: + period = row["date"][:4] if is_annual else row["date"][:7] + rev_range = _range_bar(row.get("rev_lo"), row.get("rev_avg"), row.get("rev_hi"), rev_lo_min, rev_hi_max) + rev_avg_str = fmt_large(row["rev_avg"]) if row.get("rev_avg") else "—" + eps_range = _range_bar(row.get("eps_lo"), row.get("eps_avg"), row.get("eps_hi"), eps_lo_min, eps_hi_max) + eps_avg_str = fmt_currency(row["eps_avg"]) if row.get("eps_avg") else "—" + ebitda_str = fmt_large(row["ebitda_avg"]) if row.get("ebitda_avg") else "—" + analysts_str = str(row["n_analysts"]) if row.get("n_analysts") else "—" + tbody.append(f'{period}{rev_range}{rev_avg_str}{eps_range}{eps_avg_str}{ebitda_str}{analysts_str}') + return "\n".join(tbody) + + annual_rows = _parse_est_rows(annual) + quarterly_rows = _parse_est_rows(quarterly) + + # Historical revenue from income statement + inc = get_income_statement(ticker) + hist_rev = {} + if inc is not None and not inc.empty and "Total Revenue" in inc.index: + rev_series = inc.loc["Total Revenue"].dropna() + for col in rev_series.index: + yr = str(col)[:4] + v = rev_series[col] + if v and pd.notna(v): + hist_rev[yr] = float(v) / 1e9 + hist_rev = dict(sorted(hist_rev.items())) + + # Compute lede stats + next_year_rev = annual_rows[0].get("rev_avg") if annual_rows else None + next_year_eps = annual_rows[0].get("eps_avg") if annual_rows else None + next_year_period = annual_rows[0]["date"][:4] if annual_rows else "—" + max_analysts = max((r.get("n_analysts") or 0) for r in annual_rows) if annual_rows else 0 + + # Revenue CAGR + cagr = None + if len(annual_rows) >= 2 and annual_rows[0].get("rev_avg") and annual_rows[-1].get("rev_avg"): + n_years = len(annual_rows) + cagr = (float(annual_rows[-1]["rev_avg"]) / float(annual_rows[0]["rev_avg"])) ** (1 / max(n_years - 1, 1)) - 1 + + # Narrative readout + if cagr is not None: + if cagr > 0.12: + fwd_readout = f"Analysts project accelerating growth — revenue expected to compound at {cagr*100:.0f}% annually over the forecast horizon." + elif cagr > 0.05: + fwd_readout = f"Steady expansion in view — consensus projects {cagr*100:.0f}% annual revenue growth through {annual_rows[-1]['date'][:4] if annual_rows else 'end of period'}." + elif cagr > 0: + fwd_readout = f"Modest growth expected — analysts see {cagr*100:.0f}% annual expansion with limited upside surprise potential." else: - st.info("No quarterly estimates available.") + fwd_readout = "Analysts project revenue contraction or flat growth over the forecast period." + else: + fwd_readout = "Analyst estimates show the expected trajectory for revenue and earnings per share." + + # Build chart data + hist_years = list(hist_rev.keys())[-5:] + hist_vals = [hist_rev[y] for y in hist_years] + bridge_yr = hist_years[-1] if hist_years else None + bridge_val = hist_vals[-1] if hist_vals else None + fwd_years = [r["date"][:4] for r in annual_rows] + fwd_avg = [float(r["rev_avg"]) / 1e9 if r["rev_avg"] else None for r in annual_rows] + fwd_lo = [float(r["rev_lo"]) / 1e9 if r.get("rev_lo") else None for r in annual_rows] + fwd_hi = [float(r["rev_hi"]) / 1e9 if r.get("rev_hi") else None for r in annual_rows] + if bridge_yr and bridge_val: + fwd_years = [bridge_yr] + fwd_years + fwd_avg = [bridge_val] + fwd_avg + fwd_lo = [bridge_val] + fwd_lo + fwd_hi = [bridge_val] + fwd_hi + + chart_json = json.dumps({ + "hist_years": hist_years, + "hist_vals": hist_vals, + "fwd_years": fwd_years, + "fwd_avg": fwd_avg, + "fwd_lo": fwd_lo, + "fwd_hi": fwd_hi, + }) + + annual_tbody = _build_est_table_html(annual_rows, is_annual=True) if annual_rows else "" + qtr_tbody = _build_est_table_html(quarterly_rows, is_annual=False) if quarterly_rows else "" + + last_period = annual_rows[-1]["date"][:4] if annual_rows else "—" + + # Format lede values + rev_str = fmt_large(next_year_rev) if next_year_rev else "—" + eps_str = fmt_currency(next_year_eps) if next_year_eps else "—" + cagr_str = f"{cagr*100:.1f}%" if cagr is not None else "—" + + qtr_section = f'
Quarterly estimates require FMP premium subscription.
' if not qtr_tbody else f"""
II

Quarterly detail

Quarterly estimates
{qtr_tbody}
PeriodRevenue RangeRev AvgEPS RangeEPS AvgEBITDAAnalysts
""" + + doc = f""" + + + + + + + +
+
+ Wall Street outlook +
What {max_analysts} analysts project for the years ahead
+

Annual consensus estimates sourced from Financial Modeling Prep. The revenue chart shows historical revenue alongside the analyst range — dashed line is the average estimate, the band spans the bull-to-bear spectrum.

+
+
+
{next_year_period} Revenue{rev_str}{max_analysts} analysts · consensus
+
{next_year_period} EPS{eps_str}consensus estimate
+
Rev. CAGR{cagr_str}est. through {last_period}
+
+
+ + +
+ + +
+ +
+ +
+
+
I

Revenue trajectory

+ Historical + analyst consensus range +
+
+
+
Historical
+
Est. Avg
+
Est. Range (low–high)
+
+
{fwd_readout}
+
+ + +
+
+
+
II

Annual estimates

+ Revenue · EPS · EBITDA · Coverage +
+
+ + + + + + {annual_tbody if annual_tbody else ''} +
PeriodRevenue RangeRev AvgEPS RangeEPS AvgEBITDAAnalysts
No annual estimates available.
+
+
+ + + + +""" + + height = 680 + len(annual_rows) * 46 + components.html(doc, height=height, scrolling=False) -- cgit v1.3-2-g0d8e