diff options
| -rw-r--r-- | components/valuation.py | 1386 |
1 files changed, 981 insertions, 405 deletions
diff --git a/components/valuation.py b/components/valuation.py index c794a79..db352b3 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -3216,124 +3216,93 @@ def _render_comps(ticker: str): # ── 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; } -""" +_AT_CSS = """<style> +.at-body{padding:var(--sp-5) var(--sp-5) var(--sp-7);display:flex;flex-direction:column;gap:var(--sp-5)} +.at-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)} +.at-lede .left{display:flex;flex-direction:column;gap:8px} +.at-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:40ch} +.at-lede .sub{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);line-height:1.55;max-width:64ch} +.at-lede .right{display:flex;flex-direction:column;gap:var(--sp-2)} +.at-source{background:var(--ink-2);border:1px solid var(--line-1);border-radius:var(--r-2);padding:var(--sp-3) var(--sp-4);display:flex;flex-direction:column;gap:2px} +.at-source .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:600} +.at-source .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-14);color:var(--fg-1);font-weight:500} +.at-source .v.pos{color:var(--positive)}.at-source .v.neg{color:var(--negative)} +.at-source .cap{font-family:var(--font-mono);font-size:10px;color:var(--fg-4)} +.at-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.at-card-head{padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--line-1);display:flex;justify-content:space-between;align-items:baseline} +.at-card-head .left-group{display:flex;align-items:baseline;gap:var(--sp-2)} +.at-card-head .roman{font-family:var(--font-display);font-style:italic;font-size:var(--fs-20);color:var(--brass);font-weight:400;margin-right:6px} +.at-card-head h3{font-family:var(--font-display);font-size:var(--fs-20);font-weight:500;letter-spacing:-0.01em;color:var(--fg-1);margin:0} +.at-card-head .hint{font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)} +.at-track-wrap{padding:var(--sp-4) var(--sp-5) 0} +.stat-row{display:grid;grid-template-columns:repeat(5,1fr);gap:var(--sp-2);padding:var(--sp-3) var(--sp-5) var(--sp-4)} +.stat-card{background:var(--ink-2);border:1px solid var(--line-1);border-radius:var(--r-2);padding:var(--sp-3)} +.stat-card .lbl{display:block;font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);margin-bottom:4px;font-weight:500} +.stat-card .val{display:block;font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-20);font-weight:500;color:var(--brass-bright);line-height:1.1} +.stat-card .val.dim{color:var(--fg-1)} +.stat-card .val.pos{color:var(--positive)}.stat-card .val.neg{color:var(--negative)} +.pos{color:var(--positive)}.neg{color:var(--negative)} +.at-readout{font-family:var(--font-display);font-style:italic;font-size:var(--fs-14);color:var(--fg-2);padding:var(--sp-4) var(--sp-5);border-top:1px solid var(--line-1);line-height:1.55} +.rec-wrap{padding:var(--sp-4) var(--sp-5) var(--sp-5)} +.rec-stacked{height:24px;border-radius:var(--r-2);overflow:hidden;display:flex;margin-bottom:var(--sp-3)} +.rec-seg{height:100%} +.rec-legend{display:flex;gap:var(--sp-4);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-family:var(--font-sans);font-size:var(--fs-12);color:var(--fg-2)} +.rec-legend .count{font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-1)} +.rec-legend .pct{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)} +</style>""" # ── Analyst Targets ────────────────────────────────────────────────────────── def _render_analyst_targets(ticker: str): + import json as _json + targets = get_analyst_price_targets(ticker) recs = get_recommendations_summary(ticker) + info = get_company_info(ticker) if not targets and (recs is None or recs.empty): st.info("Analyst data unavailable.") return - # ── 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 + # Extract targets + current = float(targets.get("current") or 0) + low = float(targets.get("low") or 0) + mean_t = float(targets.get("mean") or 0) + median_t = float(targets.get("median") or 0) + high = float(targets.get("high") or 0) - # Upside calculation upside = (mean_t - current) / current if current > 0 and mean_t else None + upside_str = f"{upside * 100:+.1f}%" if upside is not None else "—" + upside_cls = "pos" if (upside or 0) > 0 else "neg" - # ── SVG range bar positioning (0–800 scale) ── - span = high - low if high > low else 1 - - def _pct(v): - return (v - low) / span if span > 0 else 0 - - px_current = _pct(current) * 800 - px_mean = _pct(mean_t) * 800 - - # Clamp to avoid edge clipping (8–792 px range) - px_current = max(8, min(792, px_current)) - px_mean = max(8, min(792, px_mean)) - - # ── Extract recommendations ── + # Extract recommendations counts = {"Strong Buy": 0, "Buy": 0, "Hold": 0, "Sell": 0, "Strong Sell": 0} if recs is not None and not recs.empty: if "period" in recs.columns: - row = recs[recs["period"] == "0m"] - if not row.empty: - row = row.iloc[0] - else: - row = recs.iloc[0] + row_r = recs[recs["period"] == "0m"] + row_r = row_r.iloc[0] if not row_r.empty else recs.iloc[0] else: - row = recs.iloc[0] - - counts["Strong Buy"] = int(row.get("strongBuy", 0)) - counts["Buy"] = int(row.get("buy", 0)) - counts["Hold"] = int(row.get("hold", 0)) - counts["Sell"] = int(row.get("sell", 0)) - counts["Strong Sell"] = int(row.get("strongSell", 0)) + row_r = recs.iloc[0] + counts["Strong Buy"] = int(row_r.get("strongBuy", 0)) + counts["Buy"] = int(row_r.get("buy", 0)) + counts["Hold"] = int(row_r.get("hold", 0)) + counts["Sell"] = int(row_r.get("sell", 0)) + counts["Strong Sell"] = int(row_r.get("strongSell", 0)) total = sum(counts.values()) - # ── Narrative readouts ── - upside_str = f"{upside*100:+.1f}%" if upside is not None else "—" - upside_cls = "pos" if (upside or 0) > 0 else "neg" - + # Narrative readouts if upside and upside > 0.20: - readout = f"Consensus sees significant upside — analysts expect {upside*100:.0f}% appreciation from current levels." + 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." + 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." + 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." + readout = f"Targets trail price — mean consensus implies {abs(upside) * 100:.0f}% downside from current." else: readout = "Analyst consensus on price targets." @@ -3348,193 +3317,661 @@ def _render_analyst_targets(ticker: str): 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." + consensus_readout = "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"/>' + # SVG track (800px internal coordinate) + _span = high - low if high > low else 1 + + def _pct_pos(v): + return max(0.0, min(1.0, (v - low) / _span)) if _span > 0 else 0.5 + + px_low_x = 20 + px_high_x = 780 + px_w = px_high_x - px_low_x + px_current = max(28, min(772, px_low_x + _pct_pos(current) * px_w)) + px_mean = max(28, min(772, px_low_x + _pct_pos(mean_t) * px_w)) + + if mean_t > current and current > 0: + fill_x = min(px_current, px_mean) + fill_w = abs(px_mean - px_current) + svg_fill = f'<rect x="{fill_x:.0f}" y="46" width="{fill_w:.0f}" height="8" fill="rgba(79,140,94,0.2)" 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"/>' + fill_x = min(px_current, px_mean) + fill_w = abs(px_mean - px_current) + svg_fill = f'<rect x="{fill_x:.0f}" y="46" width="{fill_w:.0f}" height="8" fill="rgba(181,73,75,0.2)" rx="2"/>' + else: + svg_fill = "" - 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>""" + svg_html = ( + '<svg viewBox="0 0 800 100" preserveAspectRatio="xMidYMid meet" width="100%" height="90"' + ' xmlns="http://www.w3.org/2000/svg">' + '<rect x="20" y="46" width="760" height="8" rx="4" fill="#222934"/>' + '<rect x="20" y="46" width="760" height="8" rx="4" fill="rgba(31,61,92,0.22)"/>' + + svg_fill + + '<line x1="20" x2="20" y1="38" y2="62" stroke="#2E3645" stroke-width="1.5"/>' + '<line x1="780" x2="780" y1="38" y2="62" stroke="#2E3645" stroke-width="1.5"/>' + + f'<text x="20" y="78" font-size="10" fill="#5E5849" font-family="IBM Plex Mono,monospace" text-anchor="start">{fmt_currency(low)}</text>' + + f'<text x="780" y="78" font-size="10" fill="#5E5849" font-family="IBM Plex Mono,monospace" text-anchor="end">{fmt_currency(high)}</text>' + + f'<circle cx="{px_current:.0f}" cy="50" r="6" fill="#8E8676" stroke="#0B0E13" stroke-width="2"/>' + + f'<text x="{px_current:.0f}" y="94" font-size="9" fill="#8E8676" font-family="IBM Plex Mono,monospace" text-anchor="middle">Current {fmt_currency(current)}</text>' + + f'<circle cx="{px_mean:.0f}" cy="50" r="14" fill="none" stroke="rgba(194,170,122,0.18)" stroke-width="4"/>' + + f'<circle cx="{px_mean:.0f}" cy="50" r="9" fill="#C2AA7A" stroke="#0B0E13" stroke-width="2"/>' + + f'<text x="{px_mean:.0f}" y="94" font-size="9" fill="#C2AA7A" font-family="IBM Plex Mono,monospace" text-anchor="middle" font-weight="500">Mean {fmt_currency(mean_t)}</text>' + + '</svg>' + ) - # ── 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>' + # Stat cards + def _sc(lbl, val_str, val_cls=""): + cls_str = (' ' + val_cls) if val_cls else '' + return ( + '<div class="stat-card">' + '<span class="lbl">' + lbl + '</span>' + '<span class="val' + cls_str + ' num">' + val_str + '</span>' + '</div>' + ) - 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 = ( + '<div class="stat-row">' + + _sc("Low", fmt_currency(low), "dim") + + _sc("Mean", fmt_currency(mean_t)) + + _sc("Median", fmt_currency(median_t), "dim") + + _sc("High", fmt_currency(high), "dim") + + _sc("Upside to mean", upside_str, upside_cls) + + '</div>' ) - stat_html = f'<div class="stat-row">{stat_row}</div>' - # ── Build recommendation stacked bar + legend ── - rec_colors = {"Strong Buy": "#2E5A35", "Buy": "#4F8C5E", "Hold": "#8F7A50", "Sell": "#8B3A3F", "Strong Sell": "#6E2A2E"} + # Recommendation 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(): + color = rec_colors[label] 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%)" + pct_w = count / total * 100 + bar_segs += f'<div class="rec-seg" style="width:{pct_w:.1f}%;background:{color}"></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>' + '<div class="rec-legend-item">' + '<div class="rec-dot" style="background:' + color + '"></div>' + '<span class="name">' + label + '</span>' + '<span class="count">' + str(count) + '</span>' + '<span class="pct">' + pct_str + '</span>' + '</div>' ) - # ── 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> + # Context strip + 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 = "—", "" + _XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} + raw_x = (info.get("exchange", "") if info else "") or "" + exchange = _XMAP.get(raw_x, raw_x) or "—" + price_str = f"${price:.2f}" if price else "—" -<!-- 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> + 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 · Analyst Targets</span>' + '<div class="meta">' + '<span>' + exchange + '</span>' + '<span class="px num">' + price_str + '</span>' + '<span class="' + chg_cls + ' num">' + chg_str + '</span>' + '</div></div>' + ) -<!-- 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> + lede_html = ( + '<section class="at-lede">' + '<div class="left">' + '<span class="eyebrow-lbl">Analyst coverage</span>' + '<h2 class="ttl">Where the street sets its sights — ' + str(total) + ' analysts, one consensus</h2>' + '<p class="sub">Price targets and recommendation breakdown as of the current reporting period. ' + 'The range bar shows where the 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">' + str(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 ' + upside_cls + ' num">' + upside_str + '</span>' + '<span class="cap">to mean target</span></div>' + '</div>' + '</section>' + ) -<!-- 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> + card1_html = ( + '<section 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>' + '<div class="at-track-wrap">' + svg_html + '</div>' + + stat_html + + '<div class="at-readout">' + readout + '</div>' + '</section>' + ) + + card2_html = ( + '<section 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">' + str(total) + ' analysts · current month</span>' + '</div>' + '<div class="rec-wrap">' + '<div class="rec-stacked">' + bar_segs + '</div>' + '<div class="rec-legend">' + legend_items + '</div>' + '</div>' + '<div class="at-readout">' + consensus_readout + '</div>' + '</section>' + ) + + foot_html = ( + '<div class="va-foot">' + '<span>Price targets and recommendations sourced from yfinance. ' + 'Coverage counts as of the most recent reporting month.</span>' + '</div>' + ) + + body = ( + ctx_html + + '<div class="at-body">' + + lede_html + + card1_html + + card2_html + + foot_html + + '</div>' + ) + + _ROOT = ( + "<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;--brass-ink:#17120A;" + "--oxford:#1F3D5C;--oxford-light:#2E5A87;" + "--positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517;" + "--warning:#C49545;--warning-bg:#2A1F0F;--info:#4A78B5;--info-bg:#11202E;" + "--font-display:'EB Garamond',Georgia,serif;" + "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;" + "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;" + "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;" + "--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;--fs-38:2.375rem;" + "--tr-wider:0.12em;--tr-wide:0.04em;--tr-tight:-0.02em;" + "--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;" + "--shadow-1:0 1px 3px rgba(0,0,0,0.4);" + "}" + "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>" + ) -</body></html>""" + 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;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap'" + " rel='stylesheet'>" + + _ROOT + + _KR_CSS + _AT_CSS + + "</head><body>" + + body + + "</body></html>" + ) - components.html(doc, height=700, scrolling=False) + components.html(doc, height=1200, scrolling=False) # ── Earnings History ────────────────────────────────────────────────────────── +_EH_CSS = """<style> +.eh-body{padding:var(--sp-5) var(--sp-5) var(--sp-7);display:flex;flex-direction:column;gap:var(--sp-5)} +.eh-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)} +.eh-lede .left{display:flex;flex-direction:column;gap:8px} +.eh-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:42ch} +.eh-lede .sub{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);line-height:1.55;max-width:64ch} +.eh-lede .right{display:flex;flex-direction:column;gap:var(--sp-2)} +.eh-source{background:var(--ink-2);border:1px solid var(--line-1);border-radius:var(--r-2);padding:var(--sp-3) var(--sp-4);display:flex;flex-direction:column;gap:2px} +.eh-source .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:600} +.eh-source .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-14);color:var(--fg-1);font-weight:500} +.eh-source .cap{font-family:var(--font-mono);font-size:10px;color:var(--fg-4)} +.eh-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3)} +.eh-card-head{padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--line-1);display:flex;justify-content:space-between;align-items:center} +.eh-card-head .left-group{display:flex;align-items:baseline;gap:var(--sp-2)} +.eh-card-head .roman{font-family:var(--font-display);font-style:italic;font-size:var(--fs-20);color:var(--brass);font-weight:400;margin-right:6px} +.eh-card-head h3{font-family:var(--font-display);font-size:var(--fs-20);font-weight:500;letter-spacing:-0.01em;color:var(--fg-1);margin:0} +.eh-card-head .hint{font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)} +.eh-stat-strip{display:flex;gap:0;border-bottom:1px solid var(--line-1)} +.eh-stat-cell{padding:var(--sp-3) var(--sp-5);display:flex;flex-direction:column;gap:2px;border-right:1px solid var(--line-1)} +.eh-stat-cell:last-child{border-right:none} +.eh-stat-cell .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:600} +.eh-stat-cell .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-18);color:var(--fg-1);font-weight:500} +.eh-stat-cell .v.pos{color:var(--positive)}.eh-stat-cell .v.neg{color:var(--negative)} +.eh-table{width:100%;border-collapse:collapse} +.eh-table thead th{background:var(--ink-2);font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);padding:8px var(--sp-4);text-align:left;font-weight:600;border-bottom:1px solid var(--line-1)} +.eh-table thead th.r{text-align:right}.eh-table thead th.c{text-align:center} +.eh-table tbody tr{border-bottom:1px solid var(--line-1);transition:background .06s} +.eh-table tbody tr:hover{filter:brightness(1.08)} +.eh-table tbody tr:last-child{border-bottom:none} +.eh-table td{padding:10px var(--sp-4);font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-13);color:var(--fg-2)} +.eh-table td.r{text-align:right}.eh-table td.c{text-align:center} +.eh-table td.pos{color:var(--positive)}.eh-table td.neg{color:var(--negative)} +.pos{color:var(--positive)}.neg{color:var(--negative)} +</style>""" + + def _render_earnings_history(ticker: str): + import json as _json + eh = get_earnings_history(ticker) next_date = get_next_earnings_date(ticker) - - if next_date: - st.info(f"Next earnings date: **{next_date}**") + info = get_company_info(ticker) if eh is None or eh.empty: st.info("Earnings history unavailable for this ticker.") return - st.markdown("**Historical EPS: Actual vs. Estimate**") + # Build normalized row list, oldest first (for chart) + df = eh.copy().sort_index() + rows = [] + for idx in df.index: + def _safe_float(col): + try: + v = df.loc[idx, col] if col in df.columns else None + return float(v) if v is not None and pd.notna(v) else None + except (TypeError, ValueError): + return None + + actual_f = _safe_float("epsActual") + est_f = _safe_float("epsEstimate") + diff_f = _safe_float("epsDifference") + surprise_f = _safe_float("surprisePercent") + beat = (actual_f >= est_f) if (actual_f is not None and est_f is not None) else None + + rows.append({ + "quarter": str(idx)[:10], + "epsActual": actual_f, + "epsEstimate": est_f, + "diff": diff_f, + "surprisePct": surprise_f, + "beat": beat, + }) + + n_total = len(rows) + + # Compute stats + beats = [r for r in rows if r["beat"] is True] + beat_rate = len(beats) / n_total * 100 if n_total > 0 else 0 + + surprise_vals = [r["surprisePct"] for r in rows if r["surprisePct"] is not None] + avg_surprise = sum(surprise_vals) / len(surprise_vals) if surprise_vals else None + med_surprise = sorted(surprise_vals)[len(surprise_vals) // 2] if surprise_vals else None + + # Current streak (from most recent) + streak_count = 0 + streak_type = None + for r in reversed(rows): + if r["beat"] is None: + break + if streak_type is None: + streak_type = r["beat"] + if r["beat"] == streak_type: + streak_count += 1 + else: + break + + if streak_count > 0 and streak_type is not None: + streak_str = f"{streak_count} {'beats' if streak_type else 'misses'}" + streak_cls = "pos" if streak_type else "neg" + else: + streak_str = "—" + streak_cls = "" + + # Build SVG chart (oldest to newest on x-axis) + n = len(rows) + SVG_W, SVG_H = 800, 260 + PAD_L, PAD_R, PAD_T, PAD_B = 64, 24, 20, 56 + + all_eps = [] + for r in rows: + if r["epsActual"] is not None: + all_eps.append(r["epsActual"]) + if r["epsEstimate"] is not None: + all_eps.append(r["epsEstimate"]) + + if all_eps: + y_min_raw = min(all_eps) + y_max_raw = max(all_eps) + y_pad = (y_max_raw - y_min_raw) * 0.18 or 0.1 + y_min = y_min_raw - y_pad + y_max = y_max_raw + y_pad + else: + y_min, y_max = -1.0, 1.0 + + y_span = (y_max - y_min) or 1.0 + ch_h = SVG_H - PAD_T - PAD_B + ch_w = SVG_W - PAD_L - PAD_R + + def _cx(i): + return PAD_L + (i / max(n - 1, 1)) * ch_w if n > 1 else PAD_L + ch_w / 2 + + def _cy(v): + return PAD_T + (1.0 - (v - y_min) / y_span) * ch_h + + svg_parts = [ + f'<svg viewBox="0 0 {SVG_W} {SVG_H}" width="100%"' + f' style="display:block;overflow:visible" xmlns="http://www.w3.org/2000/svg">' + ] + + # Horizontal grid lines + for frac in [0.0, 0.25, 0.5, 0.75, 1.0]: + gy = PAD_T + frac * ch_h + gv = y_max - frac * y_span + svg_parts.append( + f'<line x1="{PAD_L}" x2="{SVG_W - PAD_R}" y1="{gy:.1f}" y2="{gy:.1f}"' + f' stroke="#1A2030" stroke-width="1"/>' + f'<text x="{PAD_L - 6}" y="{gy + 3.5:.1f}" font-size="9" fill="#5E5849"' + f' font-family="IBM Plex Mono,monospace" text-anchor="end">{gv:.2f}</text>' + ) + + # Zero line + if y_min < 0 < y_max: + zy = _cy(0) + svg_parts.append( + f'<line x1="{PAD_L}" x2="{SVG_W - PAD_R}" y1="{zy:.1f}" y2="{zy:.1f}"' + f' stroke="#2E3645" stroke-width="1.5" stroke-dasharray="4,3"/>' + ) + + # Estimate line (dashed oxford-light) + est_pts = [(i, rows[i]["epsEstimate"]) for i in range(n) if rows[i]["epsEstimate"] is not None] + if len(est_pts) >= 2: + est_d = " ".join( + f"{'M' if j == 0 else 'L'}{_cx(i):.1f} {_cy(v):.1f}" + for j, (i, v) in enumerate(est_pts) + ) + svg_parts.append( + f'<path d="{est_d}" fill="none" stroke="#2E5A87" stroke-width="1.5" stroke-dasharray="5,3"/>' + ) + + # Actual line (solid brass) + act_pts = [(i, rows[i]["epsActual"]) for i in range(n) if rows[i]["epsActual"] is not None] + if len(act_pts) >= 2: + act_d = " ".join( + f"{'M' if j == 0 else 'L'}{_cx(i):.1f} {_cy(v):.1f}" + for j, (i, v) in enumerate(act_pts) + ) + svg_parts.append( + f'<path d="{act_d}" fill="none" stroke="#C2AA7A" stroke-width="2"/>' + ) + + # Dots and x-axis labels + for i, r in enumerate(rows): + xi = _cx(i) + if r["epsEstimate"] is not None: + yi = _cy(r["epsEstimate"]) + svg_parts.append( + f'<circle cx="{xi:.1f}" cy="{yi:.1f}" r="4" fill="#2E5A87" stroke="#0B0E13" stroke-width="1.5"/>' + ) + if r["epsActual"] is not None: + ya = _cy(r["epsActual"]) + dot_color = "#4F8C5E" if r["beat"] is True else ("#B5494B" if r["beat"] is False else "#C2AA7A") + svg_parts.append( + f'<circle cx="{xi:.1f}" cy="{ya:.1f}" r="5.5" fill="{dot_color}" stroke="#0B0E13" stroke-width="2"/>' + ) + label = r["quarter"][:7] + ly = SVG_H - PAD_B + 14 + svg_parts.append( + f'<text x="{xi:.1f}" y="{ly}" font-size="9" fill="#5E5849"' + f' font-family="IBM Plex Mono,monospace" text-anchor="end"' + f' transform="rotate(-40,{xi:.1f},{ly})">{label}</text>' + ) + + svg_parts.append('</svg>') + svg_html = "".join(svg_parts) + + # EPS table (most recent first) + def _pill(beat): + if beat is True: + return ( + '<span style="background:rgba(79,140,94,0.18);color:#4F8C5E;' + 'font-family:IBM Plex Mono,monospace;font-size:10px;text-transform:uppercase;' + 'letter-spacing:0.08em;padding:2px 8px;border-radius:999px">Beat</span>' + ) + if beat is False: + return ( + '<span style="background:rgba(181,73,75,0.18);color:#B5494B;' + 'font-family:IBM Plex Mono,monospace;font-size:10px;text-transform:uppercase;' + 'letter-spacing:0.08em;padding:2px 8px;border-radius:999px">Miss</span>' + ) + return '<span style="color:#5E5849;font-family:IBM Plex Mono,monospace;font-size:10px">—</span>' + + table_rows_html = "" + for r in reversed(rows): + beat = r["beat"] + row_bg = ( + "rgba(79,140,94,0.05)" if beat is True + else ("rgba(181,73,75,0.05)" if beat is False else "transparent") + ) + eps_actual_str = fmt_currency(r["epsActual"]) if r["epsActual"] is not None else "—" + eps_est_str = fmt_currency(r["epsEstimate"]) if r["epsEstimate"] is not None else "—" + diff_str = (("+" if (r["diff"] or 0) >= 0 else "") + fmt_currency(abs(r["diff"])) if r["diff"] is not None else "—") + if r["diff"] is not None: + diff_str = ("+" if r["diff"] >= 0 else "") + fmt_currency(r["diff"]) + diff_cls = "pos" if (r["diff"] or 0) >= 0 else "neg" + if r["surprisePct"] is not None: + surp_str = f"{r['surprisePct'] * 100:+.2f}%" + else: + surp_str = "—" + surp_cls = "pos" if (r["surprisePct"] or 0) >= 0 else "neg" + pill = _pill(beat) + table_rows_html += ( + f'<tr style="background:{row_bg}">' + f'<td class="num">{r["quarter"]}</td>' + f'<td class="num r">{eps_est_str}</td>' + f'<td class="num r">{eps_actual_str}</td>' + f'<td class="num r {diff_cls}">{diff_str}</td>' + f'<td class="num r {surp_cls}">{surp_str}</td>' + f'<td class="c">{pill}</td>' + '</tr>' + ) + + # Stat strip + beat_rate_str = f"{beat_rate:.0f}%" + avg_surp_str = (f"{avg_surprise * 100:+.1f}%" if avg_surprise is not None else "—") + avg_surp_cls = "pos" if (avg_surprise or 0) >= 0 else "neg" + + stat_strip_html = ( + '<div class="eh-stat-strip">' + '<div class="eh-stat-cell"><span class="lbl">Beat rate</span>' + '<span class="v pos num">' + beat_rate_str + '</span></div>' + '<div class="eh-stat-cell"><span class="lbl">Avg surprise</span>' + '<span class="v ' + avg_surp_cls + ' num">' + avg_surp_str + '</span></div>' + '<div class="eh-stat-cell"><span class="lbl">Current streak</span>' + '<span class="v ' + streak_cls + ' num">' + streak_str + '</span></div>' + '</div>' + ) + + # Context strip + 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 = "—", "" + _XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} + raw_x = (info.get("exchange", "") if info else "") or "" + exchange = _XMAP.get(raw_x, raw_x) or "—" + price_str = f"${price:.2f}" if price else "—" + + 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 · Earnings History</span>' + '<div class="meta">' + '<span>' + exchange + '</span>' + '<span class="px num">' + price_str + '</span>' + '<span class="' + chg_cls + ' num">' + chg_str + '</span>' + '</div></div>' + ) - df = eh.copy().sort_index(ascending=False) - df.index = df.index.astype(str) - df.index.name = "Quarter" + next_date_str = next_date if next_date else "Not scheduled" + med_surp_str = (f"{med_surprise * 100:+.1f}%" if med_surprise is not None else "—") - display = pd.DataFrame(index=df.index) - display["EPS Actual"] = df["epsActual"].apply(fmt_currency) - display["EPS Estimate"] = df["epsEstimate"].apply(fmt_currency) - display["Surprise"] = df["epsDifference"].apply( - lambda v: f"{'+' if float(v) >= 0 else ''}{fmt_currency(v)}" - if pd.notna(v) else "—" + lede_html = ( + '<section class="eh-lede">' + '<div class="left">' + '<span class="eyebrow-lbl">Earnings track record</span>' + '<h2 class="ttl">' + str(n_total) + ' quarters — beat rate ' + f"{beat_rate:.0f}%" + ', streak ' + streak_str + '</h2>' + '<p class="sub">Quarterly EPS actuals versus analyst consensus estimates. ' + 'Green dots indicate beats, red misses. The strip below tracks beat rate, average surprise, and current streak.</p>' + '</div>' + '<div class="right">' + '<div class="eh-source"><span class="lbl">Next earnings</span>' + '<span class="v num">' + next_date_str + '</span>' + '<span class="cap">estimated date</span></div>' + '<div class="eh-source"><span class="lbl">Median surprise</span>' + '<span class="v num">' + med_surp_str + '</span>' + '<span class="cap">vs consensus</span></div>' + '<div class="eh-source"><span class="lbl">Current streak</span>' + '<span class="v ' + streak_cls + ' num">' + streak_str + '</span>' + '<span class="cap">consecutive</span></div>' + '</div>' + '</section>' ) - display["Surprise %"] = df["surprisePercent"].apply( - lambda v: f"{float(v) * 100:+.2f}%" if pd.notna(v) else "—" + + chart_legend = ( + '<div style="display:flex;gap:20px;padding:8px 20px 4px;align-items:center">' + '<span style="display:flex;align-items:center;gap:6px;font-family:IBM Plex Mono,monospace;font-size:11px;color:#8E8676">' + '<svg width="18" height="3" style="flex-shrink:0">' + '<line x1="0" y1="1.5" x2="18" y2="1.5" stroke="#C2AA7A" stroke-width="2"/>' + '</svg>Actual EPS</span>' + '<span style="display:flex;align-items:center;gap:6px;font-family:IBM Plex Mono,monospace;font-size:11px;color:#8E8676">' + '<svg width="18" height="3" style="flex-shrink:0">' + '<line x1="0" y1="1.5" x2="18" y2="1.5" stroke="#2E5A87" stroke-width="1.5" stroke-dasharray="4,2"/>' + '</svg>Est. EPS</span>' + '<span style="display:flex;align-items:center;gap:5px;font-family:IBM Plex Mono,monospace;font-size:11px;color:#8E8676">' + '<svg width="9" height="9" style="flex-shrink:0">' + '<circle cx="4.5" cy="4.5" r="4" fill="#4F8C5E"/>' + '</svg>Beat</span>' + '<span style="display:flex;align-items:center;gap:5px;font-family:IBM Plex Mono,monospace;font-size:11px;color:#8E8676">' + '<svg width="9" height="9" style="flex-shrink:0">' + '<circle cx="4.5" cy="4.5" r="4" fill="#B5494B"/>' + '</svg>Miss</span>' + '</div>' ) - def highlight_surprise(row): - try: - pct_str = row["Surprise %"].replace("%", "").replace("+", "") - val = float(pct_str) - color = "rgba(46,204,113,0.15)" if val >= 0 else "rgba(231,76,60,0.15)" - return ["", "", f"background-color: {color}", f"background-color: {color}"] - except Exception: - return [""] * len(row) + chart_card_html = ( + '<section class="eh-card">' + '<div class="eh-card-head">' + '<div class="left-group"><span class="roman">I</span><h3>EPS: actual vs. estimate</h3></div>' + + chart_legend + + '</div>' + '<div style="padding:12px 16px 8px">' + svg_html + '</div>' + '</section>' + ) - st.dataframe( - display.style.apply(highlight_surprise, axis=1), - width="stretch", - hide_index=False, + table_card_html = ( + '<section class="eh-card" style="overflow:hidden">' + '<div class="eh-card-head">' + '<div class="left-group"><span class="roman">II</span><h3>Quarterly detail</h3></div>' + '<span class="hint">Most recent first · ' + str(n_total) + ' quarters</span>' + '</div>' + + stat_strip_html + + '<table class="eh-table">' + '<thead><tr>' + '<th>Quarter</th>' + '<th class="r">EPS Est</th>' + '<th class="r">EPS Actual</th>' + '<th class="r">Surprise $</th>' + '<th class="r">Surprise %</th>' + '<th class="c">Result</th>' + '</tr></thead>' + '<tbody>' + table_rows_html + '</tbody>' + '</table>' + '</section>' ) - st.download_button( - "Download CSV", - display.to_csv().encode(), - file_name=f"{ticker.upper()}_earnings_history.csv", - mime="text/csv", - key=f"dl_earnings_{ticker}", + + foot_html = ( + '<div class="va-foot">' + '<span>Earnings history from yfinance. Surprise % relative to analyst consensus at report time.</span>' + + ('<span style="font-family:IBM Plex Mono,monospace;color:#C2AA7A">Next: ' + next_date + '</span>' if next_date else '') + + '</div>' ) - # EPS chart — oldest to newest - df_chart = eh.sort_index() - fig = go.Figure() - fig.add_trace(go.Scatter( - x=df_chart.index.astype(str), - y=df_chart["epsActual"], - name="Actual EPS", - mode="lines+markers", - line=dict(color="#C2AA7A", width=2), - )) - fig.add_trace(go.Scatter( - x=df_chart.index.astype(str), - y=df_chart["epsEstimate"], - name="Estimated EPS", - mode="lines+markers", - line=dict(color="#C49545", width=2, dash="dash"), - )) - fig.update_layout( - title="EPS: Actual vs. Estimate", - 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=280, - legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + body = ( + ctx_html + + '<div class="eh-body">' + + lede_html + + chart_card_html + + table_card_html + + foot_html + + '</div>' ) - st.plotly_chart(fig, width="stretch") + + _ROOT = ( + "<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;--brass-ink:#17120A;" + "--oxford:#1F3D5C;--oxford-light:#2E5A87;" + "--positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517;" + "--warning:#C49545;--warning-bg:#2A1F0F;--info:#4A78B5;--info-bg:#11202E;" + "--font-display:'EB Garamond',Georgia,serif;" + "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;" + "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;" + "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;" + "--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;--fs-38:2.375rem;" + "--tr-wider:0.12em;--tr-wide:0.04em;--tr-tight:-0.02em;" + "--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;" + "--shadow-1:0 1px 3px rgba(0,0,0,0.4);" + "}" + "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>" + ) + + 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;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap'" + " rel='stylesheet'>" + + _ROOT + + _KR_CSS + _EH_CSS + + "</head><body>" + + body + + "</body></html>" + ) + + total_height = 1100 + n_total * 48 + components.html(doc, height=total_height, scrolling=False) # ── Historical Ratios ──────────────────────────────────────────────────────── @@ -3910,55 +4347,57 @@ 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; } -""" +_FE_CSS = """<style> +.fe-body{padding:var(--sp-5) var(--sp-5) var(--sp-7);display:flex;flex-direction:column;gap:var(--sp-5)} +.fe-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)} +.fe-lede .left{display:flex;flex-direction:column;gap:8px} +.fe-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:40ch} +.fe-lede .sub{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);line-height:1.55;max-width:64ch} +.fe-lede .right{display:flex;flex-direction:column;gap:var(--sp-2)} +.fe-source{background:var(--ink-2);border:1px solid var(--line-1);border-radius:var(--r-2);padding:var(--sp-3) var(--sp-4);display:flex;flex-direction:column;gap:2px} +.fe-source .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:600} +.fe-source .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-14);color:var(--fg-1);font-weight:500} +.fe-source .cap{font-family:var(--font-mono);font-size:10px;color:var(--fg-4)} +.tab-row{display:flex;gap:var(--sp-2)} +.tab-pill{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;padding:4px 12px;border-radius:var(--r-full);cursor:pointer;border:none} +.tab-pill.active{background:var(--brass);color:var(--brass-ink);font-weight:600} +.tab-pill.inactive{background:var(--ink-2);border:1px solid var(--line-2);color:var(--fg-3)} +.fe-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.fe-card-head{padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--line-1);display:flex;justify-content:space-between;align-items:baseline} +.fe-card-head .left-group{display:flex;align-items:baseline;gap:var(--sp-2)} +.fe-card-head .roman{font-family:var(--font-display);font-style:italic;font-size:var(--fs-20);color:var(--brass);font-weight:400;margin-right:6px} +.fe-card-head h3{font-family:var(--font-display);font-size:var(--fs-20);font-weight:500;letter-spacing:-0.01em;color:var(--fg-1);margin:0} +.fe-card-head .hint{font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)} +.fe-chart-wrap{padding:var(--sp-3) var(--sp-4) 0} +#rev-chart{width:100%;height:280px} +.chart-legend{display:flex;gap:var(--sp-5);padding:var(--sp-2) var(--sp-4) var(--sp-3)} +.legend-item{display:flex;align-items:center;gap:7px;font-family:var(--font-sans);font-size:11px;color:var(--fg-3)} +.legend-swatch{width:18px;height:2px;flex-shrink:0} +.legend-swatch.solid{background:var(--brass)} +.legend-swatch.dashed{background:transparent;border-bottom:2px dashed var(--brass)} +.legend-swatch.band{background:rgba(31,61,92,0.7);height:8px;border-radius:2px} +.fe-readout{font-family:var(--font-display);font-style:italic;font-size:var(--fs-14);color:var(--fg-2);padding:var(--sp-4) var(--sp-5);border-top:1px solid var(--line-1);line-height:1.55} +.fe-table-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.fe-table-head-section{padding:var(--sp-5) var(--sp-5) 0} +table.fe-table{width:100%;border-collapse:collapse} +table.fe-table thead th{background:var(--ink-2);font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);padding:8px var(--sp-4);text-align:right;font-weight:600;border-bottom:1px solid var(--line-1)} +table.fe-table thead th:first-child{text-align:left} +table.fe-table tbody tr{border-bottom:1px solid var(--line-1)} +table.fe-table tbody tr:last-child{border-bottom:none} +table.fe-table tbody tr:hover{background:rgba(194,170,122,0.04)} +table.fe-table td{padding:10px var(--sp-4);font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-13);color:var(--fg-2);text-align:right;vertical-align:middle} +table.fe-table td:first-child{text-align:left;color:var(--fg-1);font-weight:500} +.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:var(--ink-3);border-radius:2px} +.range-mini-band{position:absolute;height:4px;top:8px;background:rgba(31,61,92,0.55);border-radius:2px} +.range-mini-dot{position:absolute;width:7px;height:7px;top:6px;border-radius:50%;background:var(--brass);border:1.5px solid var(--ink-0)} +.info-banner{background:var(--ink-2);border-left:3px solid var(--oxford);color:var(--fg-3);font-family:var(--font-sans);font-size:var(--fs-12);padding:var(--sp-3) var(--sp-4);border-radius:0 var(--r-2) var(--r-2) 0;margin:var(--sp-4)} +</style>""" + def _render_forward_estimates(ticker: str): + import json as _json + with st.spinner("Loading forward estimates…"): estimates = get_analyst_estimates(ticker) @@ -3969,8 +4408,7 @@ def _render_forward_estimates(ticker: str): st.info("Forward estimates unavailable. Requires FMP API key.") return - def _parse_est_rows(rows: list[dict]) -> list[dict]: - """Parse raw FMP estimate rows into normalized structure.""" + def _parse_est_rows(rows): parsed = [] for row in sorted(rows, key=lambda r: str(r.get("date", ""))): rev_avg = row.get("revenueAvg") or row.get("estimatedRevenueAvg") @@ -3980,7 +4418,12 @@ 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") - n_analysts = row.get("numAnalystsRevenue") or row.get("numAnalystsEps") or row.get("numberAnalystEstimatedRevenue") or row.get("numberAnalysts") + 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, @@ -3994,28 +4437,32 @@ def _render_forward_estimates(ticker: str): }) return parsed - def _range_bar(lo, avg, hi, lo_min, hi_max) -> str: - """Render a mini range bar with low–high band and average marker.""" + def _range_bar(lo, avg, hi, lo_min, hi_max): if not lo or not hi or not avg: - return '<span style="color:#5E5849">—</span>' + return '<span style="color:var(--fg-4)">—</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>' + rng = hi_max_f - lo_min_f + if rng <= 0: + return '<span style="color:var(--fg-4)">—</span>' + lo_pct = (lo_f - lo_min_f) / rng * 100 + hi_pct = (hi_f - lo_min_f) / rng * 100 + avg_pct = (avg_f - lo_min_f) / rng * 100 + return ( + '<div class="range-mini">' + '<div class="range-mini-track"></div>' + f'<div class="range-mini-band" style="left:{lo_pct:.1f}%;right:{100 - hi_pct:.1f}%"></div>' + f'<div class="range-mini-dot" style="left:calc({avg_pct:.1f}% - 3.5px)"></div>' + '</div>' + ) - def _build_est_table_html(rows: list[dict], is_annual: bool = True) -> str: - """Build HTML table body for estimates.""" + def _build_est_table_html(rows, is_annual=True): 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")] + all_rev_lo = [r["rev_lo"] for r in rows if r.get("rev_lo")] + all_rev_hi = [r["rev_hi"] for r in rows if r.get("rev_hi")] + all_eps_lo = [r["eps_lo"] for r in rows if r.get("eps_lo")] + all_eps_hi = [r["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 @@ -4029,13 +4476,23 @@ def _render_forward_estimates(ticker: str): 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>') + tbody.append( + '<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) annual_rows = _parse_est_rows(annual) quarterly_rows = _parse_est_rows(quarterly) - # Historical revenue from income statement + # Historical revenue inc = get_income_statement(ticker) hist_rev = {} if inc is not None and not inc.empty and "Total Revenue" in inc.index: @@ -4047,32 +4504,30 @@ def _render_forward_estimates(ticker: str): hist_rev[yr] = float(v) / 1e9 hist_rev = dict(sorted(hist_rev.items())) - # Compute lede stats + # Lede stats next_year_rev = annual_rows[0].get("rev_avg") if annual_rows else None next_year_eps = annual_rows[0].get("eps_avg") if annual_rows else None next_year_period = annual_rows[0]["date"][:4] if annual_rows else "—" max_analysts = max((r.get("n_analysts") or 0) for r in annual_rows) if annual_rows else 0 - # Revenue CAGR cagr = None if len(annual_rows) >= 2 and annual_rows[0].get("rev_avg") and annual_rows[-1].get("rev_avg"): n_years = len(annual_rows) cagr = (float(annual_rows[-1]["rev_avg"]) / float(annual_rows[0]["rev_avg"])) ** (1 / max(n_years - 1, 1)) - 1 - # Narrative readout if cagr is not None: if cagr > 0.12: - fwd_readout = f"Analysts project accelerating growth — revenue expected to compound at {cagr*100:.0f}% annually over the forecast horizon." + 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'}." + 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." + fwd_readout = f"Modest growth expected — analysts see {cagr * 100:.0f}% annual expansion with limited upside surprise potential." else: fwd_readout = "Analysts project revenue contraction or flat growth over the forecast period." else: fwd_readout = "Analyst estimates show the expected trajectory for revenue and earnings per share." - # Build chart data + # 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 @@ -4087,120 +4542,241 @@ def _render_forward_estimates(ticker: str): fwd_lo = [bridge_val] + fwd_lo fwd_hi = [bridge_val] + fwd_hi - chart_json = json.dumps({ + chart_data = { "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 "—" + cagr_str = f"{cagr * 100:.1f}%" if cagr is not None else "—" + + # Context strip + info = get_company_info(ticker) + 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 = "—", "" + _XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} + raw_x = (info.get("exchange", "") if info else "") or "" + exchange = _XMAP.get(raw_x, raw_x) or "—" + price_str = f"${price:.2f}" if price 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>""" + 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 · Forward Estimates</span>' + '<div class="meta">' + '<span>' + exchange + '</span>' + '<span class="px num">' + price_str + '</span>' + '<span class="' + chg_cls + ' num">' + chg_str + '</span>' + '</div></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_html = ( + '<section class="fe-lede">' + '<div class="left">' + '<span class="eyebrow-lbl">Wall Street outlook</span>' + '<h2 class="ttl">What ' + str(max_analysts) + ' analysts project for the years ahead</h2>' + '<p class="sub">Annual consensus estimates sourced from Financial Modeling Prep. ' + 'The revenue chart bridges historical actuals to the analyst range — dashed line is the consensus average, ' + '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">' + str(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>' + ) -<!-- 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> + tab_row_html = ( + '<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>' + ) -<!-- 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> + annual_table_empty = ( + '<tr><td colspan="7" style="padding:16px;text-align:center;color:var(--fg-3)">' + 'No annual estimates available.</td></tr>' + ) -<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> + annual_content_html = ( + '<div id="annual-content">' + '<section 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 class="fe-chart-wrap"><div id="rev-chart"></div></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="fe-readout">' + fwd_readout + '</div>' + '</section>' + '<section 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 class="fe-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 annual_table_empty) + '</tbody>' + '</table>' + '</section>' + '</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> + if qtr_tbody: + qtr_content_html = ( + '<div id="qtr-content" style="display:none">' + '<section 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 class="fe-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>' + '</section>' + '</div>' + ) + else: + qtr_content_html = ( + '<div id="qtr-content" style="display:none">' + '<div class="info-banner">Quarterly estimates require FMP premium subscription.</div>' + '</div>' + ) -<div id="qtr-content" style="display:none"> - {qtr_section} -</div> + foot_html = ( + '<div class="va-foot">' + '<span>Forward estimates from Financial Modeling Prep. Historical revenue from yfinance. ' + 'CAGR computed over the full estimate horizon.</span>' + '</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'; -}} + body = ( + ctx_html + + '<div class="fe-body">' + + lede_html + + tab_row_html + + annual_content_html + + qtr_content_html + + foot_html + + '</div>' + ) -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>""" + js = ( + "const D=" + _json.dumps(chart_data) + ";\n" + "function showTab(tab,el){" + "document.querySelectorAll('.tab-pill').forEach(function(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';" + "}\n" + "var 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',type:'scatter'}," + "{x:D.hist_years,y:D.hist_vals,name:'Historical',mode:'lines+markers',type:'scatter'," + "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',type:'scatter'}," + "{x:D.fwd_years,y:D.fwd_hi,fill:'tonexty',fillcolor:'rgba(31,61,92,0.22)'," + "line:{color:'transparent'},showlegend:false,hoverinfo:'skip',type:'scatter'}," + "{x:D.fwd_years,y:D.fwd_avg,name:'Est. avg',mode:'lines+markers',type:'scatter'," + "line:{color:'#C2AA7A',width:1.5,dash:'dash'},marker:{size:5,color:'#C2AA7A'},showlegend:false}" + "];\n" + "var 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}" + "};\n" + "Plotly.newPlot('rev-chart',traces,layout,{responsive:true,displayModeBar:false});\n" + ) + + _ROOT = ( + "<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;--brass-ink:#17120A;" + "--oxford:#1F3D5C;--oxford-light:#2E5A87;" + "--positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517;" + "--warning:#C49545;--warning-bg:#2A1F0F;--info:#4A78B5;--info-bg:#11202E;" + "--font-display:'EB Garamond',Georgia,serif;" + "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;" + "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;" + "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;" + "--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;--fs-38:2.375rem;" + "--tr-wider:0.12em;--tr-wide:0.04em;--tr-tight:-0.02em;" + "--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;" + "--shadow-1:0 1px 3px rgba(0,0,0,0.4);" + "}" + "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>" + ) + + 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;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>" + + _ROOT + + _KR_CSS + _FE_CSS + + "</head><body>" + + body + + "<script>" + js + "</script>" + + "</body></html>" + ) - height = 680 + len(annual_rows) * 46 + height = 1320 + len(annual_rows) * 50 components.html(doc, height=height, scrolling=False) + |
