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