diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-05-15 01:42:37 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-05-15 01:42:37 -0700 |
| commit | a1015045ea0693664faf3ce1fe010e52be8af103 (patch) | |
| tree | 94647a1f1c74a967ddfe1035650bcb2f3673e68a | |
| parent | 764cd69bfc2e5a0cf504c8d6e4f032d35edd9a4c (diff) | |
Redesigned valuation tabs
| -rw-r--r-- | components/valuation.py | 1138 |
1 files changed, 921 insertions, 217 deletions
diff --git a/components/valuation.py b/components/valuation.py index 010c831..0758bdf 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -2758,100 +2758,516 @@ def _render_models(ticker: str): # ── Comps Table ────────────────────────────────────────────────────────────── +_CC_CSS = """<style> +.cmp-body{padding:var(--sp-5) var(--sp-6) var(--sp-7);display:flex;flex-direction:column;gap:var(--sp-5);flex:1} +.cmp-lede{display:grid;grid-template-columns:1.6fr 1fr;gap:var(--sp-5);align-items:stretch;background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-5)} +.cmp-lede .left{display:flex;flex-direction:column;gap:8px} +.cmp-lede .ttl{font-family:var(--font-display);font-size:var(--fs-30);font-weight:500;letter-spacing:-0.01em;line-height:1.1;color:var(--fg-1);margin:4px 0 0;max-width:38ch} +.cmp-lede .sub{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);line-height:1.55;max-width:62ch} +.cmp-lede .right{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-3);align-content:end} +.cmp-source{display:flex;flex-direction:column;gap:2px;padding:var(--sp-3) var(--sp-4);background:var(--ink-2);border:1px solid var(--line-1);border-radius:var(--r-2)} +.cmp-source .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600} +.cmp-source .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-14);color:var(--fg-1);font-weight:500} +.cmp-source .cap{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)} +.cmp-hero{display:grid;grid-template-columns:repeat(4,1fr);background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.cmp-rank{padding:var(--sp-4) var(--sp-5);border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:var(--sp-3)} +.cmp-rank:last-child{border-right:none} +.cmp-rank-head{display:flex;justify-content:space-between;align-items:baseline} +.cmp-rank-head .lbl{font-family:var(--font-sans);font-size:var(--fs-12);text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600;white-space:nowrap} +.cmp-rank-head .pct{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-18);font-weight:500} +.cmp-rank-head .pct.pos{color:var(--positive)}.cmp-rank-head .pct.neg{color:var(--negative)} +.cmp-rank-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-3)} +.cmp-rank-row .col{display:flex;flex-direction:column;gap:2px} +.cmp-rank-row .col .sub{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3)} +.cmp-rank-row .col .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-24);color:var(--brass-bright);font-weight:500;line-height:1} +.cmp-rank-row .col .v.dim{color:var(--fg-2)} +.cmp-rank-track{position:relative;height:24px;margin-top:6px} +.cmp-rank-track .t{position:absolute;inset:8px 0;background:var(--ink-3);border-radius:999px} +.cmp-rank-track .band{position:absolute;top:6px;bottom:6px;background:rgba(74,120,181,0.22);border-top:1px solid rgba(74,120,181,0.30);border-bottom:1px solid rgba(74,120,181,0.30)} +.cmp-rank-track .median{position:absolute;top:3px;bottom:3px;width:1.5px;background:var(--oxford-light);transform:translateX(-0.75px)} +.cmp-rank-track .peer-dot{position:absolute;top:50%;width:6px;height:6px;border-radius:50%;background:var(--fg-3);transform:translate(-3px,-50%);opacity:0.7} +.cmp-rank-track .subject{position:absolute;top:50%;width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid var(--ink-1);transform:translate(-7px,-50%);box-shadow:0 0 0 1px var(--brass-deep),0 0 0 4px rgba(194,170,122,0.18);z-index:2} +.cmp-rank-track .axis{position:absolute;top:100%;left:0;right:0;display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:10px;color:var(--fg-4);margin-top:4px} +.cmp-rank .readout{font-family:var(--font-display);font-style:italic;font-size:var(--fs-14);color:var(--fg-2);margin-top:18px;padding-top:6px;border-top:1px solid var(--line-1)} +.cmp-table-wrap{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.cmp-table-head{padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--line-1);display:flex;justify-content:space-between;align-items:baseline} +.cmp-table-head>div{display:flex;align-items:baseline;gap:var(--sp-2)} +.cmp-table-head .eyebrow{font-family:var(--font-display);font-style:italic;font-size:var(--fs-20);color:var(--brass);text-transform:none;letter-spacing:0;font-weight:400} +.cmp-table-head h3{font-family:var(--font-display);font-size:var(--fs-20);font-weight:500;margin:0;color:var(--fg-1)} +.cmp-table-head .hint{font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)} +.cmp-table{display:flex;flex-direction:column} +.cmp-header,.cmp-row{display:grid;align-items:center;gap:var(--sp-3);padding:0 var(--sp-5)} +.cmp-header{background:var(--ink-2);border-bottom:1px solid var(--line-2);padding-top:10px;padding-bottom:10px;position:sticky;top:0;z-index:2} +.cmp-header .th{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600;cursor:pointer;user-select:none} +.cmp-header .th:hover{color:var(--fg-1)}.cmp-header .th .arr{color:var(--brass)}.cmp-header .th.r{text-align:right}.cmp-header .th.sym,.cmp-header .th.name{cursor:default} +.cmp-row{padding-top:10px;padding-bottom:10px;border-bottom:1px solid var(--line-1);transition:background .08s ease} +.cmp-row:last-child{border-bottom:none}.cmp-row:hover{background:rgba(194,170,122,0.03)} +.cmp-row .sym{font-family:var(--font-mono);font-size:var(--fs-13);color:var(--fg-1);font-weight:500;display:flex;align-items:center;gap:6px} +.cmp-row .name{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2)} +.cmp-row .name .muted{color:var(--fg-3);font-size:11px;margin-left:4px} +.cmp-row .mc{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-13);color:var(--fg-1);text-align:right} +.cmp-row .mc.dim{color:var(--fg-3)} +.cmp-row.subject{background:rgba(194,170,122,0.06);border-bottom:1px solid rgba(194,170,122,0.2)} +.cmp-row.subject .sym{color:var(--brass-bright);font-weight:600} +.cmp-row.subject .sym .pin{font-family:var(--font-sans);font-size:9px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--brass);background:rgba(194,170,122,0.10);border:1px solid rgba(194,170,122,0.30);padding:1px 5px;border-radius:var(--r-1);font-weight:600} +.cmp-row.subject .name{color:var(--fg-1)} +.cmp-row.median{background:var(--ink-2);border-bottom:1px solid var(--line-2)} +.cmp-row.median .sym{color:var(--fg-4)}.cmp-row.median .name{color:var(--fg-3);font-style:italic} +.cmp-cell{display:flex;flex-direction:column;align-items:flex-end;gap:4px} +.cmp-cell .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-13);color:var(--fg-1)} +.cmp-cell .v.dim{color:var(--fg-3)}.cmp-cell.hl .v{color:var(--brass-bright);font-weight:600} +.cmp-track{position:relative;height:4px;width:100%;background:var(--ink-3);border-radius:999px} +.cmp-track .median{position:absolute;top:-1px;bottom:-1px;left:50%;width:1px;background:var(--fg-4)} +.cmp-track .dot{position:absolute;top:50%;width:7px;height:7px;border-radius:50%;transform:translate(-3.5px,-50%);border:1.5px solid var(--ink-1)} +.cmp-track .dot.pos{background:var(--positive)}.cmp-track .dot.neg{background:var(--negative)} +.cmp-track .dot.subject{background:var(--brass);width:9px;height:9px;transform:translate(-4.5px,-50%);box-shadow:0 0 0 1px var(--brass-deep)} +.cmp-cell.median-cell{align-items:flex-end;justify-content:center;gap:0} +</style>""" + + def _render_comps(ticker: str): + import json as _json + info = get_company_info(ticker) auto_peers = get_peers(ticker) - suggested_peers = _suggest_peer_tickers(ticker, info) + 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 - 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()}", - ) + with st.spinner("Loading comps…"): + ratios_list = get_ratios_for_tickers(all_syms) - 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.") + if not ratios_list: + st.info("No ratio data available for the peer set.") + return - 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) + ratios_map = {r["symbol"].upper(): r for r in ratios_list} - all_tickers = [ticker.upper()] + peer_list[:9] + 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}, + ] - with st.spinner("Loading comps..."): - ratios_list = get_ratios_for_tickers(all_tickers) + 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), + } - if not ratios_list: - st.info("Could not load ratios for the selected peer companies.") - return + _XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} - 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", - } + 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) - 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"] + 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), + } - 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) + 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 - def _format_comp_value(column: str, value): - if value is None: - return "—" - try: - v = float(value) - except (TypeError, ValueError): - return "—" + HERO_COLS = ["pe", "evEbt", "revG", "opM"] + subject_row = next((p for p in peers if p["subject"]), None) - 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 "—" + def _pctof(vals, v): + if not vals: + return 50 + return round(sum(1 for x in vals if x <= v) / len(vals) * 100) - for col in df.columns: - if col == "Ticker": + 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 highlight_subject(row): - if row["Ticker"] == ticker.upper(): - return ["background-color: rgba(79,142,247,0.15)"] * len(row) - return [""] * len(row) + def _pos(v_in, mn=st_data["min"], sp=span): + return round(max(0.0, min(100.0, (v_in - mn) / sp * 100)), 1) - st.dataframe( - df.style.apply(highlight_subject, axis=1), - width="stretch", - hide_index=True, + 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"], + }) + + 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 "—" + + 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 = ( + '<div class="val-ctx">' + '<span class="sym">' + sym + '</span>' + '<span class="name">' + name + '</span>' + '<span class="eyebrow-ctx" style="margin-left:12px">Valuation · Comps</span>' + '<div class="meta">' + '<span>' + exchange + '</span>' + '<span class="px num">' + price_str + '</span>' + '<span class="' + chg_cls + ' num">' + chg_str + '</span>' + '</div></div>' + ) + + lede_html = ( + '<section class="cmp-lede">' + '<div class="left">' + '<span class="eyebrow-lbl">Peer set</span>' + '<h2 class="ttl">' + str(n_peers) + ' names, one table — read across to see where ' + sym + ' sits</h2>' + '<p class="sub">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.</p>' + '</div>' + '<div class="right">' + '<div class="cmp-source"><span class="lbl">Peer set</span>' + '<span class="v num">' + str(n_peers) + ' names</span>' + '<span class="cap">Sector · similar market cap</span></div>' + '<div class="cmp-source"><span class="lbl">Tagging</span>' + '<span class="v num">Auto-matched</span>' + '<span class="cap">FMP peers · 6h cache</span></div>' + '<div class="cmp-source"><span class="lbl">Period</span>' + '<span class="v num">TTM</span>' + '<span class="cap">Prices live · ratios T-1</span></div>' + '</div>' + '</section>' + ) + + hero_html = '<section class="cmp-hero" id="cmp-hero"></section>' + + table_html = ( + '<section class="cmp-table-wrap">' + '<div class="cmp-table-head">' + '<div><span class="eyebrow">Side by side</span>' + '<h3>Peer comparison · TTM ratios</h3></div>' + '<span class="hint">Click a column header to sort · dot in each cell shows column percentile</span>' + '</div>' + '<div class="cmp-table" id="cmp-table"></div>' + '</section>' + ) + + foot_html = ( + '<div class="va-foot">' + '<span>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.</span>' + '</div>' ) + body = ctx_html + '<div class="cmp-body">' + lede_html + hero_html + table_html + foot_html + '</div>' + + 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<h.length;i++){\n" + " var c=h[i],pctCls=c.good?'pos':'neg',dots='';\n" + " for(var j=0;j<c.peerPositions.length;j++){\n" + " dots+='<div class=\"peer-dot\" style=\"left:'+c.peerPositions[j]+'%\"></div>';\n" + " }\n" + " html+='<div class=\"cmp-rank\">';\n" + " html+='<div class=\"cmp-rank-head\"><span class=\"lbl\">'+c.lbl+'</span>';\n" + " html+='<span class=\"pct num '+pctCls+'\">P'+c.pct+'</span></div>';\n" + " html+='<div class=\"cmp-rank-row\">';\n" + " html+='<div class=\"col\"><span class=\"sub\">'+DATA.subject+'</span>';\n" + " html+='<span class=\"v num\">'+fmtV(c.value,c.kind)+'</span></div>';\n" + " html+='<div class=\"col\"><span class=\"sub\">Peer median</span>';\n" + " html+='<span class=\"v num dim\">'+fmtV(c.median,c.kind)+'</span></div></div>';\n" + " html+='<div class=\"cmp-rank-track\">';\n" + " html+='<div class=\"t\"></div>';\n" + " html+='<div class=\"band\" style=\"left:'+c.p25Pos+'%;right:'+(100-c.p75Pos)+'%\"></div>';\n" + " html+='<div class=\"median\" style=\"left:'+c.medPos+'%\"></div>';\n" + " html+=dots;\n" + " html+='<div class=\"subject\" style=\"left:'+c.subjPos+'%\"></div>';\n" + " html+='<div class=\"axis\"><span>'+fmtV(c.minV,c.kind)+'</span>';\n" + " html+='<span>'+fmtV(c.maxV,c.kind)+'</span></div></div>';\n" + " html+='<span class=\"readout\">'+c.readout+'</span></div>';\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<DATA.cols.length;i++){if(DATA.cols[i].key===colKey){col=DATA.cols[i];break;}}\n" + " if(v===null||v===undefined||st.min===null){\n" + " return'<div class=\"cmp-cell'+(hl?' hl':'')+'\"><span class=\"v num dim\">—</span></div>';\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'<div class=\"cmp-cell'+(hl?' hl':'')+'\">'"\ + "+'<span class=\"v num\">'+fmtV(v,col.kind)+'</span>'"\ + "+'<div class=\"cmp-track\"><div class=\"median\"></div>'"\ + "+'<div class=\"dot '+dotCls+'\" style=\"left:'+pct.toFixed(1)+'%\"></div>'"\ + "+'</div></div>';\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<peers.length;i++){\n" + " if(peers[i].subject)subject=peers[i]; else others.push(peers[i]);\n" + " }\n" + " others.sort(function(a,b){\n" + " var va=a[sortKey],vb=b[sortKey];\n" + " if(va===null&&vb===null)return 0;\n" + " if(va===null)return 1;if(vb===null)return -1;\n" + " return sortDir==='desc'?vb-va:va-vb;\n" + " });\n" + " var n=cols.length,arr=sortDir==='desc'?' ▼':' ▲';\n" + " var colTpl='90px 1.5fr 90px ';\n" + " for(var i=0;i<n;i++)colTpl+='1fr ';\n" + " var hdr='<div class=\"cmp-header\" style=\"grid-template-columns:'+colTpl+'\">';\n" + " hdr+='<span class=\"th sym\">Ticker</span>';\n" + " hdr+='<span class=\"th name\">Company</span>';\n" + " hdr+='<span class=\"th r num\" onclick=\"cmpSort(\\'mcap\\')\">Mkt cap'+(sortKey==='mcap'?'<span class=\"arr\">'+arr+'</span>':'')+'</span>';\n" + " for(var i=0;i<cols.length;i++){\n" + " var c=cols[i];\n" + " hdr+='<span class=\"th r\" onclick=\"cmpSort(\\''+c.key+'\\')\">';\n" + " hdr+=c.short+(sortKey===c.key?'<span class=\"arr\">'+arr+'</span>':'')+'</span>';\n" + " }\n" + " hdr+='</div>';\n" + " function buildRow(p,cls){\n" + " var r='<div class=\"cmp-row'+cls+'\" style=\"grid-template-columns:'+colTpl+'\">';\n" + " if(p.subject)r+='<span class=\"sym\">'+p.sym+' <span class=\"pin\">subject</span></span>';\n" + " else r+='<span class=\"sym\">'+p.sym+'</span>';\n" + " r+='<span class=\"name\">'+p.name;\n" + " if(cls===' median')r+=' <span class=\"muted\">'+DATA.nPeers+' names</span>';\n" + " r+='</span>';\n" + " var mcCls=(cls===' median')?' dim':'';\n" + " r+='<span class=\"num r mc'+mcCls+'\">'+fmtMcap(p.mcap)+'</span>';\n" + " if(cls===' median'){\n" + " for(var i=0;i<cols.length;i++){\n" + " var c=cols[i],val=p[c.key];\n" + " r+='<div class=\"cmp-cell median-cell\"><span class=\"v num dim\">';\n" + " r+=(val!==null&&val!==undefined?fmtV(val,c.kind):'—')+'</span></div>';\n" + " }\n" + " } else {\n" + " var hl=!!p.subject;\n" + " for(var i=0;i<cols.length;i++){r+=distCell(p[cols[i].key],cols[i].key,hl);}\n" + " }\n" + " r+='</div>';return r;\n" + " }\n" + " var tbl=hdr;\n" + " if(subject)tbl+=buildRow(subject,' subject');\n" + " tbl+=buildRow(pm,' median');\n" + " for(var i=0;i<others.length;i++)tbl+=buildRow(others[i],'');\n" + " document.getElementById('cmp-table').innerHTML=tbl;\n" + "}\n" + "function cmpSort(key){\n" + " if(sortKey===key){sortDir=sortDir==='desc'?'asc':'desc';}\n" + " else{sortKey=key;sortDir='desc';}\n" + " renderTable();\n" + "}\n" + "renderHero();\n" + "renderTable();\n" + ) + + doc = ( + "<!doctype html><html><head><meta charset=\"utf-8\">" + "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">" + "<link href=\"https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500" + "&family=IBM+Plex+Mono:wght@300;400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap\" rel=\"stylesheet\">" + "<style>*,*::before,*::after{box-sizing:border-box}" + ":root{" + "--ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340;" + "--line-1:#232934;--line-2:#2E3645;--line-3:#3D4658;" + "--fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849;" + "--brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;" + "--oxford:#1F3D5C;--oxford-light:#2E5A87;" + "--positive:#4F8C5E;--negative:#B5494B;" + "--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;" + "--tr-wider:0.12em;--tr-wide:0.04em;--tr-snug:-0.01em;" + "--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;" + "--r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px;" + "}" + "html,body{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);" + "font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}" + "</style>" + + _KR_CSS + _CC_CSS + + "</head><body>" + + body + + "<script>" + js + "</script>" + + "</body></html>" + ) + 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 ────────────────────────────────────────────────────────── @@ -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") + # ── 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 - 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)) + # 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 - st.write("") + 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)**") + 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] - current_row = recs[recs["period"] == "0m"] if "period" in recs.columns else pd.DataFrame() - if current_row.empty: - current_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)) - 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()) + 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'<rect x="{min(px_current, px_mean):.0f}" y="48" width="{fill_pct:.0f}" height="4" fill="rgba(79,140,94,0.3)" rx="2"/>' + elif mean_t < current and current > 0: + fill_pct = abs(px_mean - px_current) + svg_fill = f'<rect x="{min(px_current, px_mean):.0f}" y="48" width="{fill_pct:.0f}" height="4" fill="rgba(181,73,75,0.25)" rx="2"/>' + + svg_html = f"""<svg viewBox="0 0 800 100" preserveAspectRatio="xMidYMid meet" width="100%" height="80" xmlns="http://www.w3.org/2000/svg"> + <rect x="0" y="48" width="800" height="4" rx="2" fill="#222934"/> + <rect x="0" y="48" width="800" height="4" rx="2" fill="rgba(31,61,92,0.35)"/> + {svg_fill} + <line x1="0" x2="0" y1="42" y2="58" stroke="#2E3645" stroke-width="1"/> + <line x1="800" x2="800" y1="42" y2="58" stroke="#2E3645" stroke-width="1"/> + <text x="0" y="72" font-size="10" fill="#5E5849" font-family="IBM Plex Mono,monospace" text-anchor="start">{fmt_currency(low)}</text> + <text x="800" y="72" font-size="10" fill="#5E5849" font-family="IBM Plex Mono,monospace" text-anchor="end">{fmt_currency(high)}</text> + <circle cx="{px_current:.0f}" cy="50" r="6" fill="#C7C0AE" stroke="#0B0E13" stroke-width="1.5"/> + <text x="{px_current:.0f}" y="88" font-size="10" fill="#8E8676" font-family="IBM Plex Mono,monospace" text-anchor="middle">Current {fmt_currency(current)}</text> + <circle cx="{px_mean:.0f}" cy="50" r="12" fill="none" stroke="rgba(194,170,122,0.25)" stroke-width="3"/> + <circle cx="{px_mean:.0f}" cy="50" r="8" fill="#C2AA7A" stroke="#0B0E13" stroke-width="2"/> + <text x="{px_mean:.0f}" y="88" font-size="10" fill="#C2AA7A" font-family="IBM Plex Mono,monospace" text-anchor="middle" font-weight="500">Mean {fmt_currency(mean_t)}</text> + </svg>""" - 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") + # ── Build stat cards HTML ── + def _stat(lbl, val_str, delta=None, delta_cls=""): + delta_html = f'<span class="delta {delta_cls}">{delta}</span>' if delta else '' + return f'<div class="stat-card"><span class="lbl">{lbl}</span><span class="val num">{val_str}</span>{delta_html}</div>' - st.write("") + 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'<div class="stat-row">{stat_row}</div>' - 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, + # ── 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'<div class="rec-seg" style="width:{pct:.1f}%;background:{rec_colors[label]}"></div>' + pct_str = f"({count/total*100:.0f}%)" if total > 0 else "(0%)" + legend_items += ( + f'<div class="rec-legend-item">' + f'<div class="rec-dot" style="background:{rec_colors[label]}"></div>' + f'<span class="name">{label}</span>' + f'<span class="count">{count}</span>' + f'<span class="pct">{pct_str}</span>' + f'</div>' ) - st.plotly_chart(fig, width="stretch") + + # ── Build full HTML document ── + doc = f"""<!DOCTYPE html> +<html><head><meta charset="utf-8"> +<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet"> +<style>{_AT_CSS}</style> +</head><body> + +<!-- Lede --> +<section class="at-lede"> + <div class="left"> + <span class="eyebrow-lbl">Analyst coverage</span> + <div class="ttl">Where the street sets its sights — {total} analysts, one consensus</div> + <p class="sub">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.</p> + </div> + <div class="right"> + <div class="at-source"><span class="lbl">Coverage</span><span class="v num">{total} analysts</span><span class="cap">current month</span></div> + <div class="at-source"><span class="lbl">Mean target</span><span class="v num">{fmt_currency(mean_t)}</span><span class="cap">vs {fmt_currency(current)} current</span></div> + <div class="at-source"><span class="lbl">Upside / downside</span><span class="v num {upside_cls}">{upside_str}</span><span class="cap">to mean target</span></div> + </div> +</section> + +<!-- Section I: Price Target Range --> +<div class="at-card"> + <div class="at-card-head"> + <div class="left-group"><span class="roman">I</span><h3>Price target range</h3></div> + <span class="hint">Low · Current price · Mean target · High</span> + </div> + {svg_html} + {stat_html} + <div class="readout">{readout}</div> +</div> + +<!-- Section II: Recommendation Breakdown --> +<div class="at-card"> + <div class="at-card-head"> + <div class="left-group"><span class="roman">II</span><h3>Recommendation breakdown</h3></div> + <span class="hint">{total} analysts · current month</span> + </div> + <div class="rec-stacked">{bar_segs}</div> + <div class="rec-legend">{legend_items}</div> + <div class="readout">{consensus_readout}</div> +</div> + +</body></html>""" + + 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)) + return parsed - fig = go.Figure() + 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 '<span style="color:#5E5849">—</span>' + 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 '<span style="color:#5E5849">—</span>' + 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'<div class="range-mini"><div class="range-mini-track"></div><div class="range-mini-band" style="left:{lo_pct}%;right:{100-hi_pct}%"></div><div class="range-mini-dot" style="left:calc({avg_pct}% - 3.5px)"></div></div>' - 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), - )) + 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'<tr><td>{period}</td><td>{rev_range}</td><td class="num">{rev_avg_str}</td><td>{eps_range}</td><td class="num">{eps_avg_str}</td><td class="num">{ebitda_str}</td><td class="num">{analysts_str}</td></tr>') + return "\n".join(tbody) - 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] + annual_rows = _parse_est_rows(annual) + quarterly_rows = _parse_est_rows(quarterly) - 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", - )) + # 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())) - 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"), - )) + # 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 - 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") + # 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 - 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") + # 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 annual 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." - 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") - else: - st.info("No quarterly estimates available.") + # 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'<div class="info-banner">Quarterly estimates require FMP premium subscription.</div>' if not qtr_tbody else f"""<div class="fe-table-card"><div class="fe-table-head-section"><div class="fe-card-head"><div class="left-group"><span class="roman">II</span><h3>Quarterly detail</h3></div><span class="hint">Quarterly estimates</span></div></div><table><thead><tr><th>Period</th><th>Revenue Range</th><th>Rev Avg</th><th>EPS Range</th><th>EPS Avg</th><th>EBITDA</th><th>Analysts</th></tr></thead><tbody>{qtr_tbody}</tbody></table></div>""" + + doc = f"""<!DOCTYPE html> +<html><head><meta charset="utf-8"> +<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet"> +<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" charset="utf-8"></script> +<style>{_FE_CSS}</style> +</head><body> + +<!-- Lede --> +<section class="fe-lede"> + <div class="left"> + <span class="eyebrow-lbl">Wall Street outlook</span> + <div class="ttl">What {max_analysts} analysts project for the years ahead</div> + <p class="sub">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.</p> + </div> + <div class="right"> + <div class="fe-source"><span class="lbl">{next_year_period} Revenue</span><span class="v num">{rev_str}</span><span class="cap">{max_analysts} analysts · consensus</span></div> + <div class="fe-source"><span class="lbl">{next_year_period} EPS</span><span class="v num">{eps_str}</span><span class="cap">consensus estimate</span></div> + <div class="fe-source"><span class="lbl">Rev. CAGR</span><span class="v num">{cagr_str}</span><span class="cap">est. through {last_period}</span></div> + </div> +</section> + +<!-- Tabs --> +<div class="tab-row"> + <button class="tab-pill active" onclick="showTab('annual',this)">Annual</button> + <button class="tab-pill inactive" onclick="showTab('quarterly',this)">Quarterly</button> +</div> + +<div id="annual-content"> + <!-- Section I: Revenue Chart --> + <div class="fe-card"> + <div class="fe-card-head"> + <div class="left-group"><span class="roman">I</span><h3>Revenue trajectory</h3></div> + <span class="hint">Historical + analyst consensus range</span> + </div> + <div id="rev-chart"></div> + <div class="chart-legend"> + <div class="legend-item"><div class="legend-swatch solid"></div><span>Historical</span></div> + <div class="legend-item"><div class="legend-swatch dashed"></div><span>Est. Avg</span></div> + <div class="legend-item"><div class="legend-swatch band"></div><span>Est. Range (low–high)</span></div> + </div> + <div class="readout">{fwd_readout}</div> + </div> + + <!-- Section II: Estimates Table --> + <div class="fe-table-card"> + <div class="fe-table-head-section"> + <div class="fe-card-head"> + <div class="left-group"><span class="roman">II</span><h3>Annual estimates</h3></div> + <span class="hint">Revenue · EPS · EBITDA · Coverage</span> + </div> + </div> + <table> + <thead><tr> + <th>Period</th><th>Revenue Range</th><th>Rev Avg</th> + <th>EPS Range</th><th>EPS Avg</th><th>EBITDA</th><th>Analysts</th> + </tr></thead> + <tbody>{annual_tbody if annual_tbody else '<tr><td colspan="7" style="padding:16px;text-align:center;color:#8E8676">No annual estimates available.</td></tr>'}</tbody> + </table> + </div> +</div> + +<div id="qtr-content" style="display:none"> + {qtr_section} +</div> + +<script> +function showTab(tab, el) {{ + document.querySelectorAll('.tab-pill').forEach(b => {{ b.className = 'tab-pill ' + (b === el ? 'active' : 'inactive'); }}); + document.getElementById('annual-content').style.display = tab === 'annual' ? 'block' : 'none'; + document.getElementById('qtr-content').style.display = tab === 'quarterly' ? 'block' : 'none'; +}} + +const d = JSON.parse('{chart_json.replace("'", "\\'")}'); +const traces = [ + {{x: d.hist_years, y: d.hist_vals, fill: 'tozeroy', fillcolor: 'rgba(194,170,122,0.06)', line: {{color: 'transparent'}}, showlegend: false, hoverinfo: 'skip'}}, + {{x: d.hist_years, y: d.hist_vals, name: 'Historical', mode: 'lines+markers', line: {{color: '#C2AA7A', width: 2}}, marker: {{size: 6, color: '#C2AA7A'}}, showlegend: false}}, + {{x: d.fwd_years, y: d.fwd_lo, fill: 'none', line: {{color: 'transparent'}}, showlegend: false, hoverinfo: 'skip'}}, + {{x: d.fwd_years, y: d.fwd_hi, fill: 'tonexty', fillcolor: 'rgba(31,61,92,0.22)', line: {{color: 'transparent'}}, showlegend: false, hoverinfo: 'skip'}}, + {{x: d.fwd_years, y: d.fwd_avg, name: 'Est. Avg', mode: 'lines+markers', line: {{color: '#C2AA7A', width: 1.5, dash: 'dash'}}, marker: {{size: 5, color: '#C2AA7A'}}, showlegend: false}} +]; +const layout = {{ + paper_bgcolor: '#0B0E13', plot_bgcolor: '#0B0E13', + margin: {{l: 56, r: 16, t: 8, b: 40}}, + showlegend: false, + xaxis: {{gridcolor: '#232934', tickfont: {{family: 'IBM Plex Mono,monospace', color: '#5E5849', size: 10}}, linecolor: '#232934'}}, + yaxis: {{gridcolor: '#232934', tickfont: {{family: 'IBM Plex Mono,monospace', color: '#5E5849', size: 10}}, linecolor: '#232934', title: {{text: 'Revenue ($B)', font: {{color: '#8E8676', size: 11}}}}}}, + hovermode: 'x unified', + hoverlabel: {{bgcolor: '#181D26', bordercolor: '#2E3645', font: {{family: 'IBM Plex Mono,monospace', color: '#F2ECDC', size: 11}}}}, + font: {{family: 'IBM Plex Mono,monospace', color: '#C7C0AE', size: 11}} +}}; +Plotly.newPlot('rev-chart', traces, layout, {{responsive: true, displayModeBar: false}}); +</script> +</body></html>""" + + height = 680 + len(annual_rows) * 46 + components.html(doc, height=height, scrolling=False) |
