From fd1482b11c2d38a3c8aae52ae47d7709a2787399 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 15 May 2026 18:45:17 -0700 Subject: Fix inner scrollbars on Forward Estimates, Analyst Targets, Earnings History tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch scrolling=True → scrolling=False and increase height estimates so iframe content never clips. Excess height is invisible whitespace matching page background. Co-Authored-By: Claude Sonnet 4.6 --- components/valuation.py | 1398 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 987 insertions(+), 411 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 = """""" # ── 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'' + # 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'' elif mean_t < current and current > 0: - fill_pct = abs(px_mean - px_current) - svg_fill = f'' - - svg_html = f""" - - - {svg_fill} - - - {fmt_currency(low)} - {fmt_currency(high)} - - Current {fmt_currency(current)} - - - Mean {fmt_currency(mean_t)} - """ - - # ── Build stat cards HTML ── - def _stat(lbl, val_str, delta=None, delta_cls=""): - delta_html = f'{delta}' if delta else '' - return f'
{lbl}{val_str}{delta_html}
' - - stat_row = ( - _stat("Low", fmt_currency(low)) + - _stat("Mean", fmt_currency(mean_t), upside_str, upside_cls) + - _stat("Median", fmt_currency(median_t)) + - _stat("High", fmt_currency(high)) + - _stat("Upside to mean", upside_str if upside is not None else "—", delta_cls=upside_cls) + fill_x = min(px_current, px_mean) + fill_w = abs(px_mean - px_current) + svg_fill = f'' + else: + svg_fill = "" + + svg_html = ( + '' + '' + '' + + svg_fill + + '' + '' + + f'{fmt_currency(low)}' + + f'{fmt_currency(high)}' + + f'' + + f'Current {fmt_currency(current)}' + + f'' + + f'' + + f'Mean {fmt_currency(mean_t)}' + + '' + ) + + # Stat cards + def _sc(lbl, val_str, val_cls=""): + cls_str = (' ' + val_cls) if val_cls else '' + return ( + '
' + '' + lbl + '' + '' + val_str + '' + '
' + ) + + stat_html = ( + '
' + + _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) + + '
' ) - stat_html = f'
{stat_row}
' - # ── 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'
' - pct_str = f"({count/total*100:.0f}%)" if total > 0 else "(0%)" + pct_w = count / total * 100 + bar_segs += f'
' + pct_str = f"({count / total * 100:.0f}%)" if total > 0 else "(0%)" legend_items += ( - f'
' - f'
' - f'{label}' - f'{count}' - f'{pct_str}' - f'
' + '
' + '
' + '' + label + '' + '' + str(count) + '' + '' + pct_str + '' + '
' ) - # ── Build full HTML document ── - doc = f""" - - - - - - -
-
- Analyst coverage -
Where the street sets its sights — {total} analysts, one consensus
-

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

-
-
-
Coverage{total} analystscurrent month
-
Mean target{fmt_currency(mean_t)}vs {fmt_currency(current)} current
-
Upside / downside{upside_str}to mean target
-
-
+ # 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 "—" - -
-
-
I

Price target range

- Low · Current price · Mean target · High -
- {svg_html} - {stat_html} -
{readout}
-
+ ctx_html = ( + '
' + '' + sym + '' + '' + name + '' + 'Valuation · Analyst Targets' + '
' + '' + exchange + '' + '' + price_str + '' + '' + chg_str + '' + '
' + ) - -
-
-
II

Recommendation breakdown

- {total} analysts · current month -
-
{bar_segs}
-
{legend_items}
-
{consensus_readout}
-
+ lede_html = ( + '
' + '
' + 'Analyst coverage' + '

Where the street sets its sights — ' + str(total) + ' analysts, one consensus

' + '

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.

' + '
' + '
' + '
Coverage' + '' + str(total) + ' analysts' + 'current month
' + '
Mean target' + '' + fmt_currency(mean_t) + '' + 'vs ' + fmt_currency(current) + ' current
' + '
Upside / downside' + '' + upside_str + '' + 'to mean target
' + '
' + '
' + ) + + card1_html = ( + '
' + '
' + '
I

Price target range

' + 'Low · Current price · Mean target · High' + '
' + '
' + svg_html + '
' + + stat_html + + '
' + readout + '
' + '
' + ) + + card2_html = ( + '
' + '
' + '
II

Recommendation breakdown

' + '' + str(total) + ' analysts · current month' + '
' + '
' + '
' + bar_segs + '
' + '
' + legend_items + '
' + '
' + '
' + consensus_readout + '
' + '
' + ) + + foot_html = ( + '
' + 'Price targets and recommendations sourced from yfinance. ' + 'Coverage counts as of the most recent reporting month.' + '
' + ) + + body = ( + ctx_html + + '
' + + lede_html + + card1_html + + card2_html + + foot_html + + '
' + ) + + _ROOT = ( + "" + ) -""" + doc = ( + "" + "" + "" + + _ROOT + + _KR_CSS + _AT_CSS + + "" + + body + + "" + ) - components.html(doc, height=700, scrolling=False) + components.html(doc, height=1200, scrolling=False) # ── Earnings History ────────────────────────────────────────────────────────── +_EH_CSS = """""" + + 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 - df = eh.copy().sort_index(ascending=False) - df.index = df.index.astype(str) - df.index.name = "Quarter" + 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'' + ] - 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 "—" + # 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'' + f'{gv:.2f}' + ) + + # Zero line + if y_min < 0 < y_max: + zy = _cy(0) + svg_parts.append( + f'' + ) + + # 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'' + ) + + # 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'' + ) + + # 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'' + ) + 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'' + ) + label = r["quarter"][:7] + ly = SVG_H - PAD_B + 14 + svg_parts.append( + f'{label}' + ) + + svg_parts.append('') + svg_html = "".join(svg_parts) + + # EPS table (most recent first) + def _pill(beat): + if beat is True: + return ( + 'Beat' + ) + if beat is False: + return ( + 'Miss' + ) + return '' + + 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'' + f'{r["quarter"]}' + f'{eps_est_str}' + f'{eps_actual_str}' + f'{diff_str}' + f'{surp_str}' + f'{pill}' + '' + ) + + # 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 = ( + '
' + '
Beat rate' + '' + beat_rate_str + '
' + '
Avg surprise' + '' + avg_surp_str + '
' + '
Current streak' + '' + streak_str + '
' + '
' ) - display["Surprise %"] = df["surprisePercent"].apply( - lambda v: f"{float(v) * 100:+.2f}%" if pd.notna(v) else "—" + + # 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 = ( + '
' + '' + sym + '' + '' + name + '' + 'Valuation · Earnings History' + '
' + '' + exchange + '' + '' + price_str + '' + '' + chg_str + '' + '
' ) - 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) + 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 "—") - st.dataframe( - display.style.apply(highlight_surprise, axis=1), - width="stretch", - hide_index=False, + lede_html = ( + '
' + '
' + 'Earnings track record' + '

' + str(n_total) + ' quarters — beat rate ' + f"{beat_rate:.0f}%" + ', streak ' + streak_str + '

' + '

Quarterly EPS actuals versus analyst consensus estimates. ' + 'Green dots indicate beats, red misses. The strip below tracks beat rate, average surprise, and current streak.

' + '
' + '
' + '
Next earnings' + '' + next_date_str + '' + 'estimated date
' + '
Median surprise' + '' + med_surp_str + '' + 'vs consensus
' + '
Current streak' + '' + streak_str + '' + 'consecutive
' + '
' + '
' + ) + + chart_legend = ( + '
' + '' + '' + '' + 'Actual EPS' + '' + '' + '' + 'Est. EPS' + '' + '' + '' + 'Beat' + '' + '' + '' + 'Miss' + '
' ) - 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}", + + chart_card_html = ( + '
' + '
' + '
I

EPS: actual vs. estimate

' + + chart_legend + + '
' + '
' + svg_html + '
' + '
' + ) + + table_card_html = ( + '
' + '
' + '
II

Quarterly detail

' + 'Most recent first · ' + str(n_total) + ' quarters' + '
' + + stat_strip_html + + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + table_rows_html + '' + '
QuarterEPS EstEPS ActualSurprise $Surprise %Result
' + '
' ) - # 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), + foot_html = ( + '
' + 'Earnings history from yfinance. Surprise % relative to analyst consensus at report time.' + + ('Next: ' + next_date + '' if next_date else '') + + '
' ) - st.plotly_chart(fig, width="stretch") + + body = ( + ctx_html + + '
' + + lede_html + + chart_card_html + + table_card_html + + foot_html + + '
' + ) + + _ROOT = ( + "" + ) + + doc = ( + "" + "" + "" + + _ROOT + + _KR_CSS + _EH_CSS + + "" + + body + + "" + ) + + 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 = """""" + 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 '' + return '' lo_f, avg_f, hi_f = float(lo), float(avg), float(hi) lo_min_f, hi_max_f = float(lo_min), float(hi_max) - range_span = hi_max_f - lo_min_f - if range_span <= 0: - return '' - lo_pct = ((lo_f - lo_min_f) / range_span) * 100 - hi_pct = ((hi_f - lo_min_f) / range_span) * 100 - avg_pct = ((avg_f - lo_min_f) / range_span) * 100 - return f'
' - - def _build_est_table_html(rows: list[dict], is_annual: bool = True) -> str: - """Build HTML table body for estimates.""" + rng = hi_max_f - lo_min_f + if rng <= 0: + return '' + 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 ( + '
' + '
' + f'
' + f'
' + '
' + ) + + 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'{period}{rev_range}{rev_avg_str}{eps_range}{eps_avg_str}{ebitda_str}{analysts_str}') + tbody.append( + '' + '' + period + '' + '' + rev_range + '' + '' + rev_avg_str + '' + '' + eps_range + '' + '' + eps_avg_str + '' + '' + ebitda_str + '' + '' + analysts_str + '' + '' + ) return "\n".join(tbody) annual_rows = _parse_est_rows(annual) quarterly_rows = _parse_est_rows(quarterly) - # Historical revenue from income statement + # 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 "—" - qtr_section = f'
Quarterly estimates require FMP premium subscription.
' if not qtr_tbody else f"""
II

Quarterly detail

Quarterly estimates
{qtr_tbody}
PeriodRevenue RangeRev AvgEPS RangeEPS AvgEBITDAAnalysts
""" + # 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 "—" - doc = f""" - - - - - - - -
-
- Wall Street outlook -
What {max_analysts} analysts project for the years ahead
-

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

-
-
-
{next_year_period} Revenue{rev_str}{max_analysts} analysts · consensus
-
{next_year_period} EPS{eps_str}consensus estimate
-
Rev. CAGR{cagr_str}est. through {last_period}
-
-
+ ctx_html = ( + '
' + '' + sym + '' + '' + name + '' + 'Valuation · Forward Estimates' + '
' + '' + exchange + '' + '' + price_str + '' + '' + chg_str + '' + '
' + ) - -
- - -
+ lede_html = ( + '
' + '
' + 'Wall Street outlook' + '

What ' + str(max_analysts) + ' analysts project for the years ahead

' + '

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.

' + '
' + '
' + '
' + next_year_period + ' Revenue' + '' + rev_str + '' + '' + str(max_analysts) + ' analysts · consensus
' + '
' + next_year_period + ' EPS' + '' + eps_str + '' + 'consensus estimate
' + '
Rev. CAGR' + '' + cagr_str + '' + 'est. through ' + last_period + '
' + '
' + '
' + ) -
- -
-
-
I

Revenue trajectory

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

Annual estimates

- Revenue · EPS · EBITDA · Coverage -
-
- - - - - - {annual_tbody if annual_tbody else ''} -
PeriodRevenue RangeRev AvgEPS RangeEPS AvgEBITDAAnalysts
No annual estimates available.
-
-
+ annual_table_empty = ( + '' + 'No annual estimates available.' + ) - + annual_content_html = ( + '
' + '
' + '
' + '
I

Revenue trajectory

' + 'Historical + analyst consensus range' + '
' + '
' + '
' + '
Historical
' + '
Est. avg
' + '
Est. range (low–high)
' + '
' + '
' + fwd_readout + '
' + '
' + '
' + '
' + '
' + '
II

Annual estimates

' + 'Revenue · EPS · EBITDA · Coverage' + '
' + '
' + '' + '' + '' + '' + '' + '' + (annual_tbody if annual_tbody else annual_table_empty) + '' + '
PeriodRevenue rangeRev avgEPS rangeEPS avgEBITDAAnalysts
' + '
' + '
' + ) - -""" + foot_html = ( + '
' + 'Forward estimates from Financial Modeling Prep. Historical revenue from yfinance. ' + 'CAGR computed over the full estimate horizon.' + '
' + ) + + body = ( + ctx_html + + '
' + + lede_html + + tab_row_html + + annual_content_html + + qtr_content_html + + foot_html + + '
' + ) - height = 680 + len(annual_rows) * 46 + 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 = ( + "" + ) + + doc = ( + "" + "" + "" + "" + + _ROOT + + _KR_CSS + _FE_CSS + + "" + + body + + "" + + "" + ) + + height = 1320 + len(annual_rows) * 50 components.html(doc, height=height, scrolling=False) + -- cgit v1.3-2-g0d8e