aboutsummaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/valuation.py1138
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)