diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/financials.py | 6 | ||||
| -rw-r--r-- | components/insiders.py | 4 | ||||
| -rw-r--r-- | components/options.py | 8 | ||||
| -rw-r--r-- | components/overview.py | 4 | ||||
| -rw-r--r-- | components/top_movers.py | 2 | ||||
| -rw-r--r-- | components/valuation.py | 1220 |
6 files changed, 999 insertions, 245 deletions
diff --git a/components/financials.py b/components/financials.py index 83e9a16..c01f8dc 100644 --- a/components/financials.py +++ b/components/financials.py @@ -287,13 +287,13 @@ def _render_grouped_statement(df: pd.DataFrame, groups: dict[str, list[str]], em with st.expander(section, expanded=(section == list(groups.keys())[0])): section_df = df.loc[rows] display, colors = _build_statement(section_df) - st.dataframe(_style(display, colors), use_container_width=True) + st.dataframe(_style(display, colors), width="stretch") remaining = [row for row in df.index if row not in grouped_rows] if remaining: with st.expander("Other Reported Line Items", expanded=False): display, colors = _build_statement(df.loc[remaining]) - st.dataframe(_style(display, colors), use_container_width=True) + st.dataframe(_style(display, colors), width="stretch") elif not grouped_rows: st.info(empty_msg) @@ -315,7 +315,7 @@ def _render_statement_block(title: str, df: pd.DataFrame, groups: dict[str, list _render_grouped_statement(df, groups, empty_msg) else: display, colors = _build_statement(df) - st.dataframe(_style(display, colors), use_container_width=True) + st.dataframe(_style(display, colors), width="stretch") st.download_button( "Download CSV", diff --git a/components/insiders.py b/components/insiders.py index bdb1818..1087061 100644 --- a/components/insiders.py +++ b/components/insiders.py @@ -97,7 +97,7 @@ def render_insiders(ticker: str): height=280, legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), ) - st.plotly_chart(fig, use_container_width=True) + st.plotly_chart(fig, width="stretch") st.divider() @@ -138,6 +138,6 @@ def render_insiders(ticker: str): st.dataframe( display.style.apply(_color_type, axis=1), - use_container_width=True, + width="stretch", hide_index=True, ) diff --git a/components/options.py b/components/options.py index 0acce31..b2c98c3 100644 --- a/components/options.py +++ b/components/options.py @@ -134,7 +134,7 @@ def render_options(ticker: str): legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), hovermode="x unified", ) - st.plotly_chart(fig_iv, use_container_width=True) + st.plotly_chart(fig_iv, width="stretch") # ── Open Interest by strike ─────────────────────────────────────────────── with chart_col2: @@ -172,7 +172,7 @@ def render_options(ticker: str): height=300, legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), ) - st.plotly_chart(fig_oi, use_container_width=True) + st.plotly_chart(fig_oi, width="stretch") # ── Raw chain table ─────────────────────────────────────────────────────── with st.expander("Full options chain"): @@ -187,7 +187,7 @@ def render_options(ticker: str): df_show["impliedVolatility"] = df_show["impliedVolatility"].apply( lambda v: f"{v*100:.1f}%" if pd.notna(v) else "—" ) - st.dataframe(df_show, use_container_width=True, hide_index=True) + st.dataframe(df_show, width="stretch", hide_index=True) with tab_puts: show_cols = [c for c in display_cols if c in puts.columns] @@ -197,4 +197,4 @@ def render_options(ticker: str): df_show["impliedVolatility"] = df_show["impliedVolatility"].apply( lambda v: f"{v*100:.1f}%" if pd.notna(v) else "—" ) - st.dataframe(df_show, use_container_width=True, hide_index=True) + st.dataframe(df_show, width="stretch", hide_index=True) diff --git a/components/overview.py b/components/overview.py index 9a0d162..433f6e5 100644 --- a/components/overview.py +++ b/components/overview.py @@ -329,7 +329,7 @@ def _render_relative_chart(ticker: str, info: dict, period: str): height=320, legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0), ) - st.plotly_chart(fig, use_container_width=True) + st.plotly_chart(fig, width="stretch") # ── Main render ────────────────────────────────────────────────────────────── @@ -442,4 +442,4 @@ def render_overview(ticker: str): hovermode="x unified", height=320, ) - st.plotly_chart(fig, use_container_width=True) + st.plotly_chart(fig, width="stretch") diff --git a/components/top_movers.py b/components/top_movers.py index db95592..d50eee7 100644 --- a/components/top_movers.py +++ b/components/top_movers.py @@ -143,7 +143,7 @@ def _render_mover_tab(screen: str, state_key: str): st.button( button_label, key=f"{state_key}_button", - use_container_width=True, + width="stretch", on_click=_toggle_mover_tab, args=(state_key,), ) diff --git a/components/valuation.py b/components/valuation.py index 6fc0171..010c831 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -1,5 +1,6 @@ """Valuation panel — key ratios, models, comparable companies, analyst targets, earnings history.""" import json +import numpy as np import pandas as pd import plotly.graph_objects as go import streamlit as st @@ -17,6 +18,8 @@ from services.data_service import ( get_recommendations_summary, get_earnings_history, get_next_earnings_date, + get_income_statement, + get_cash_flow, ) from services.fmp_service import ( get_key_ratios, @@ -161,154 +164,640 @@ def render_valuation(ticker: str): # ── Key Ratios ─────────────────────────────────────────────────────────────── -def _render_ratios(ticker: str): - ratios = get_key_ratios(ticker) - info = get_company_info(ticker) - - if not ratios and not info: - st.info("Ratio data unavailable.") - return +# CSS injected once per render for the Key Ratios design. +_KR_CSS = """<style> +.kr-val-wrap *,.kr-val-wrap *::before,.kr-val-wrap *::after{box-sizing:border-box} +.kr-val-wrap{background:var(--ink-0);color:var(--fg-1);font-family:var(--font-sans)} +.val-ctx{display:flex;align-items:center;gap:var(--sp-4);padding:var(--sp-3) var(--sp-5);border-bottom:1px solid var(--line-1);background:var(--ink-1)} +.val-ctx .sym{font-family:var(--font-display);font-size:var(--fs-24);font-weight:500;letter-spacing:-0.02em} +.val-ctx .name{font-family:var(--font-display);font-style:italic;font-size:var(--fs-16);color:var(--fg-2);margin-left:-4px;white-space:nowrap} +.val-ctx .eyebrow-ctx{font-family:var(--font-sans);font-size:var(--fs-12);text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600;white-space:nowrap} +.val-ctx .meta{display:flex;gap:var(--sp-4);margin-left:auto;font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)} +.val-ctx .meta span{white-space:nowrap} +.val-ctx .meta .px{color:var(--fg-1);font-size:var(--fs-14)} +.val-ctx .meta .chg-pos{color:var(--positive)}.val-ctx .meta .chg-neg{color:var(--negative)} +.num{font-family:var(--font-mono);font-variant-numeric:tabular-nums} +.eyebrow-lbl{font-family:var(--font-sans);font-size:var(--fs-12);text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600} +.kr-body{padding:var(--sp-5) var(--sp-5) var(--sp-7);display:flex;flex-direction:column;gap:var(--sp-5)} +.kr-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)} +.kr-lede .left{display:flex;flex-direction:column;gap:8px} +.kr-lede .ttl{font-family:var(--font-display);font-size:var(--fs-30);font-weight:500;letter-spacing:-0.01em;line-height:1.1;color:var(--fg-1);margin:4px 0 0;max-width:38ch} +.kr-lede .sub{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);line-height:1.55;max-width:64ch} +.kr-lede .right{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-3);align-content:end} +.kr-source{display:flex;flex-direction:column;gap:2px;padding:var(--sp-3) var(--sp-4);background:var(--ink-2);border:1px solid var(--line-1);border-radius:var(--r-2)} +.kr-source .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600} +.kr-source .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-14);color:var(--fg-1);font-weight:500} +.kr-source .cap{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)} +.kr-snapshot{display:grid;grid-template-columns:repeat(6,1fr);background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.kr-kpi{padding:var(--sp-4);border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:6px;min-height:110px} +.kr-kpi:last-child{border-right:none} +.kr-kpi .top{display:flex;justify-content:space-between;align-items:center} +.kr-kpi .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600;white-space:nowrap} +.kr-kpi .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-30);color:var(--fg-1);font-weight:500;line-height:1} +.kr-kpi .bot{display:flex;flex-direction:column;gap:2px;margin-top:auto} +.kr-kpi .sector{font-family:var(--font-mono);font-size:11px;color:var(--fg-3)} +.kr-kpi .d{font-family:var(--font-mono);font-size:11px} +.kr-kpi .d.pos{color:var(--positive)}.kr-kpi .d.neg{color:var(--negative)}.kr-kpi .d.flat{color:var(--fg-3)} +.kr-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.kr-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} +.kr-card-head>.left-group{display:flex;align-items:baseline;gap:var(--sp-2)} +.kr-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} +.kr-card-head h3{font-family:var(--font-display);font-size:var(--fs-20);font-weight:500;margin:0;color:var(--fg-1);letter-spacing:-0.01em} +.kr-card-head .hint{font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)} +.kr-rowgrid{display:grid;grid-template-columns:1.6fr 1fr 0.7fr 2fr 1fr 1.2fr;align-items:center;gap:var(--sp-4);padding:var(--sp-3) var(--sp-5);border-bottom:1px solid var(--line-1)} +.kr-rowgrid:last-child{border-bottom:none} +.kr-rowgrid.head{background:var(--ink-2);padding:8px var(--sp-5);font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600} +.kr-rowgrid .lbl{font-family:var(--font-sans);font-size:var(--fs-14);color:var(--fg-1)} +.kr-rowgrid .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-16);color:var(--fg-1);font-weight:500} +.kr-rowgrid .v.dim{color:var(--fg-2);font-size:var(--fs-13);display:inline-flex;align-items:baseline;gap:6px} +.kr-rowgrid .v.dim .mini{font-size:10px} +.kr-rowgrid .v.dim .mini.pos{color:var(--positive)}.kr-rowgrid .v.dim .mini.neg{color:var(--negative)}.kr-rowgrid .v.dim .mini.flat{color:var(--fg-3)} +.kr-rowgrid .d{font-family:var(--font-mono);font-size:var(--fs-13);font-variant-numeric:tabular-nums} +.kr-rowgrid .d.pos{color:var(--positive)}.kr-rowgrid .d.neg{color:var(--negative)}.kr-rowgrid .d.flat{color:var(--fg-3)} +.kr-rowgrid .r{text-align:right;justify-self:end} +.kr-rowgrid .peer-wrap{display:flex;flex-direction:column;gap:3px} +.kr-rowgrid .peer-axis{display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:10px;color:var(--fg-4);font-variant-numeric:tabular-nums} +.kr-rowgrid .peer-axis span:nth-child(2){color:var(--fg-3)} +.kr-peer{position:relative;height:6px;margin:2px 0} +.kr-peer-track{position:absolute;inset:0;background:var(--ink-3);border-radius:var(--r-full)} +.kr-peer-band{position:absolute;top:0;bottom:0;background:rgba(47,90,135,0.18);border-radius:2px} +.kr-peer-median{position:absolute;top:-2px;bottom:-2px;width:1.5px;background:var(--oxford-light);transform:translateX(-50%)} +.kr-peer-dot{position:absolute;width:9px;height:9px;border-radius:50%;background:var(--brass);border:1.5px solid var(--ink-0);top:50%;transform:translate(-50%,-50%);z-index:2;box-shadow:0 0 0 2px rgba(194,170,122,0.3)} +.kr-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-5)} +.kr-mini{display:grid;grid-template-columns:1.8fr 1fr 1.1fr 1.2fr;align-items:center;gap:var(--sp-3);padding:var(--sp-3) var(--sp-5);border-bottom:1px solid var(--line-1)} +.kr-mini:last-child{border-bottom:none} +.kr-mini.head{background:var(--ink-2);padding:7px var(--sp-5);font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600} +.kr-mini .lbl{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2)} +.kr-mini .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-16);color:var(--fg-1);font-weight:500} +.kr-mini .s{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-12);color:var(--fg-3);display:inline-flex;align-items:baseline;gap:4px} +.kr-mini .s .mini{font-size:10px} +.kr-mini .s .mini.pos{color:var(--positive)}.kr-mini .s .mini.neg{color:var(--negative)}.kr-mini .s .mini.flat{color:var(--fg-3)} +.kr-mini .r{justify-self:end;text-align:right} +.va-foot{font-family:var(--font-sans);font-size:var(--fs-12);color:var(--fg-3);line-height:1.6;padding:var(--sp-3) var(--sp-5);border:1px solid var(--line-1);border-radius:var(--r-2);background:var(--ink-1);display:flex;justify-content:space-between;align-items:center} +</style>""" - def _normalized_label(label: str) -> str: - return " ".join(str(label).replace("/", " ").replace("-", " ").split()).strip().lower() - def _display_value(key: str, fmt=fmt_ratio): - val = ratios.get(key) if ratios else None - return fmt(val) if val is not None else "—" +def _svg_spark(data: list, w: int = 96, h: int = 26, color: str = "var(--brass-bright)") -> str: + clean = [float(x) for x in data if x is not None and x == x] + if len(clean) < 2: + return "" + min_v, max_v = min(clean), max(clean) + span = (max_v - min_v) or 1 + dx = w / (len(clean) - 1) + pts = [(i * dx, h - ((v - min_v) / span) * (h - 4) - 2) for i, v in enumerate(clean)] + d = " ".join(f"{'M' if i == 0 else 'L'}{x:.2f} {y:.2f}" for i, (x, y) in enumerate(pts)) + lx, ly = pts[-1] + return ( + f'<svg width="{w}" height="{h}" viewBox="0 0 {w} {h}" style="display:block">' + f'<path d="{d}" fill="none" stroke="{color}" stroke-width="1.25" ' + f'stroke-linejoin="round" stroke-linecap="round"/>' + f'<circle cx="{lx:.2f}" cy="{ly:.2f}" r="1.8" fill="{color}"/>' + f'</svg>' + ) - def _company_context() -> dict: - return info or {} - def _display_reasoned_metric(key: str, fmt=fmt_ratio) -> str: - val = ratios.get(key) if ratios else None - if val is not None: - return fmt(val) +def _peer_bar_html(value, p25, p50, p75, min_v, max_v) -> str: + def pct(v): + if min_v is None or max_v is None or max_v <= min_v: + return 50.0 + return max(0.0, min(100.0, (v - min_v) / (max_v - min_v) * 100)) + vp = pct(value) if value is not None else 50 + p25p, p75p, p50p = pct(p25), pct(p75), pct(p50) + return ( + f'<div class="kr-peer">' + f'<div class="kr-peer-track"></div>' + f'<div class="kr-peer-band" style="left:{p25p:.1f}%;right:{100-p75p:.1f}%"></div>' + f'<div class="kr-peer-median" style="left:{p50p:.1f}%"></div>' + f'<div class="kr-peer-dot" style="left:{vp:.1f}%"></div>' + f'</div>' + ) - ctx = _company_context() - if key == "peRatioTTM": - trailing_pe = ctx.get("trailingPE") - if trailing_pe is not None: - return fmt_ratio(trailing_pe) - if ratios and ratios.get("netProfitMarginTTM") is not None and ratios.get("netProfitMarginTTM") < 0: - return "N/M (neg. TTM earnings)" - trailing_eps = ctx.get("trailingEps") - if trailing_eps is not None: - try: - if float(trailing_eps) <= 0: - return "N/M (neg. TTM earnings)" - except (TypeError, ValueError): - pass +def _fmtv(v, kind: str) -> str: + if v is None: + return "—" + try: + fv = float(v) + if fv != fv: return "—" + except (TypeError, ValueError): + return "—" + if kind == "%": + return f"{fv * 100:.1f}%" + if kind == "x": + return f"{fv:.1f}×" + if kind == "$B": + return f"${fv / 1e9:.1f}B" + if kind == "pp": + return f"{fv * 100:+.1f}pp" + return f"{fv:.2f}" - if key == "priceToBookRatioTTM": - book_value = ctx.get("bookValue") - if book_value is not None: - try: - if float(book_value) <= 0: - return "N/M (neg. equity)" - except (TypeError, ValueError): - pass - return "—" - if key == "enterpriseValueMultipleTTM": - ebitda = ratios.get("ebitdaTTM") if ratios else None - if ebitda is not None: - try: - if float(ebitda) <= 0: - return "N/M (neg. EBITDA)" - except (TypeError, ValueError): - pass - return "—" +def _tone(delta_pct: float, invert: bool = False) -> str: + if abs(delta_pct) < 2: + return "flat" + better = delta_pct < 0 if invert else delta_pct > 0 + return "pos" if better else "neg" - if key == "dividendPayoutRatioTTM": - payout_ratio = ctx.get("payoutRatio") - if payout_ratio is not None: - try: - if float(payout_ratio) <= 0: - return "—" - except (TypeError, ValueError): - pass - if ratios and ratios.get("netProfitMarginTTM") is not None and ratios.get("netProfitMarginTTM") < 0: - return "N/M (neg. earnings)" - return "—" - if key == "returnOnEquityTTM": - book_value = ctx.get("bookValue") - if book_value is not None: +def _compute_peer_bands(peer_ratio_rows: list[dict]) -> dict: + fields = [ + "peRatioTTM", "forwardPE", "enterpriseValueMultipleTTM", + "evToSalesTTM", "priceToBookRatioTTM", "priceToSalesRatioTTM", + "grossProfitMarginTTM", "operatingProfitMarginTTM", "netProfitMarginTTM", + "returnOnEquityTTM", "returnOnAssetsTTM", "returnOnInvestedCapitalTTM", + "currentRatioTTM", "quickRatioTTM", "debtToEquityRatioTTM", + "interestCoverageRatioTTM", "dividendYieldTTM", "dividendPayoutRatioTTM", + "revenueGrowthTTM", "earningsGrowthTTM", + ] + result = {} + for field in fields: + vals = [] + for row in peer_ratio_rows: + v = row.get(field) + if v is not None: try: - if float(book_value) <= 0: - return "N/M (neg. equity)" + fv = float(v) + if np.isfinite(fv) and fv > 0: + vals.append(fv) except (TypeError, ValueError): pass - return "—" + if len(vals) >= 2: + arr = np.array(vals) + result[field] = { + "p25": float(np.percentile(arr, 25)), + "p50": float(np.percentile(arr, 50)), + "p75": float(np.percentile(arr, 75)), + "min": float(arr.min()), + "max": float(arr.max()), + "n": len(vals), + } + return result - if key == "debtToEquityRatioTTM": - book_value = ctx.get("bookValue") - if book_value is not None: - try: - if float(book_value) <= 0: - return "N/M (neg. equity)" - except (TypeError, ValueError): - pass - return "—" - if key == "interestCoverageRatioTTM": - operating_margins = ctx.get("operatingMargins") - if operating_margins is not None: +def _compute_growth_ratios(ticker: str) -> dict: + result: dict = {} + try: + inc = get_income_statement(ticker) + cf = get_cash_flow(ticker) + + if inc is not None and not inc.empty and len(inc.columns) >= 2: + def _inc(label): + if label in inc.index: + v = inc.loc[label].dropna() + return v + return None + + rev = _inc("Total Revenue") + if rev is not None and len(rev) >= 2: + r0, r1 = float(rev.iloc[0]), float(rev.iloc[1]) + if r1 > 0: + result["revYoY"] = (r0 - r1) / r1 + if len(rev) >= 4: + r3 = float(rev.iloc[3]) + if r3 > 0 and r0 > 0: + result["rev3yrCAGR"] = (r0 / r3) ** (1 / 3) - 1 + + op_inc = _inc("Operating Income") + if op_inc is not None and len(op_inc) >= 2: + o0, o1 = float(op_inc.iloc[0]), float(op_inc.iloc[1]) + if abs(o1) > 0: + result["opIncYoY"] = (o0 - o1) / abs(o1) + + for lbl in ("Diluted Average Shares", "Diluted Common Shares Outstanding"): + shares = _inc(lbl) + if shares is not None and len(shares) >= 2: + s0, s1 = float(shares.iloc[0]), float(shares.iloc[1]) + if s1 > 0: + result["sharesYoY"] = (s0 - s1) / s1 + break + + for lbl in ("Diluted EPS", "Basic EPS"): + eps = _inc(lbl) + if eps is not None and len(eps) >= 2: + e0, e1 = float(eps.iloc[0]), float(eps.iloc[1]) + if abs(e1) > 0 and e1 > 0: + result["epsYoY"] = (e0 - e1) / e1 + break + + if cf is not None and not cf.empty: + fcf_s = None + if "Free Cash Flow" in cf.index: + fcf_s = cf.loc["Free Cash Flow"].dropna() + else: try: - if float(operating_margins) <= 0: - return "N/M (neg. EBIT)" - except (TypeError, ValueError): + op = cf.loc["Operating Cash Flow"] + capex = cf.loc["Capital Expenditure"] + fcf_s = (op + capex).dropna() + except KeyError: pass - return "—" + if fcf_s is not None and len(fcf_s) >= 2: + f0, f1 = float(fcf_s.iloc[0]), float(fcf_s.iloc[1]) + if f1 > 0: + result["fcfYoY"] = (f0 - f1) / f1 - return "—" + mkt = get_market_cap_computed(ticker) + for lbl in ("Repurchase Of Capital Stock", "Common Stock Repurchased"): + if lbl in cf.index: + val = cf.loc[lbl].iloc[0] + if val is not None and pd.notna(val): + buybacks = abs(float(val)) + if mkt and mkt > 0 and buybacks > 0: + result["buybackYield"] = buybacks / mkt + break + except Exception: + pass + return result - def _dedupe_metrics(metrics: list[tuple[str, str]]) -> list[tuple[str, str]]: - deduped: list[tuple[str, str]] = [] - seen_labels: set[str] = set() - for label, val in metrics: - norm = _normalized_label(label) - if norm in seen_labels: - continue - seen_labels.add(norm) - deduped.append((label, val)) - return deduped - rows = [ - ("Valuation", _dedupe_metrics([ - ("P/E (TTM)", _display_reasoned_metric("peRatioTTM")), - ("Forward P/E", _display_value("forwardPE")), - ("P/S (TTM)", _display_value("priceToSalesRatioTTM")), - ("P/B", _display_reasoned_metric("priceToBookRatioTTM")), - ("EV/EBITDA", _display_reasoned_metric("enterpriseValueMultipleTTM")), - ("EV/Revenue", _display_value("evToSalesTTM")), - ])), - ("Profitability", _dedupe_metrics([ - ("Gross Margin", _display_value("grossProfitMarginTTM", fmt=fmt_pct)), - ("Operating Margin", _display_value("operatingProfitMarginTTM", fmt=fmt_pct)), - ("Net Margin", _display_value("netProfitMarginTTM", fmt=fmt_pct)), - ("ROE", _display_reasoned_metric("returnOnEquityTTM", fmt=fmt_pct)), - ("ROA", _display_value("returnOnAssetsTTM", fmt=fmt_pct)), - ("ROIC", _display_value("returnOnInvestedCapitalTTM", fmt=fmt_pct)), - ])), - ("Leverage & Liquidity", _dedupe_metrics([ - ("Debt/Equity", _display_reasoned_metric("debtToEquityRatioTTM")), - ("Current Ratio", _display_value("currentRatioTTM")), - ("Quick Ratio", _display_value("quickRatioTTM")), - ("Interest Coverage", _display_reasoned_metric("interestCoverageRatioTTM")), - ("Dividend Yield", _display_value("dividendYieldTTM", fmt=fmt_pct)), - ("Payout Ratio", _display_reasoned_metric("dividendPayoutRatioTTM", fmt=fmt_pct)), - ])), - ] +def _build_hist_sparks(hist_rows: list[dict]) -> dict: + rows = list(reversed(hist_rows)) + def _ex(field): + return [r[field] for r in rows if field in r and r[field] is not None] + return { + "pe": _ex("peRatio"), + "pb": _ex("priceToBookRatio"), + "ps": _ex("priceToSalesRatio"), + "evEbt": _ex("enterpriseValueMultiple"), + "gross": _ex("grossProfitMargin"), + "op": _ex("operatingProfitMargin"), + "net": _ex("netProfitMargin"), + "roe": _ex("returnOnEquity"), + "roa": _ex("returnOnAssets"), + "de": _ex("debtEquityRatio"), + } - for section_name, metrics in rows: - st.markdown(f"**{section_name}**") - cols = st.columns(6) - for col, (label, val) in zip(cols, metrics): - col.metric(label, val) - st.write("") + +def _render_ratios(ticker: str): + info = get_company_info(ticker) + ratios = get_key_ratios(ticker) + + if not ratios and not info: + st.info("Ratio data unavailable.") + return + + price = get_latest_price(ticker) + market_cap = get_market_cap_computed(ticker) + fcf_ttm = get_free_cash_flow_ttm(ticker) + hist_rows = get_historical_ratios(ticker, limit=7) + + # Peer set + peers_raw = get_peers(ticker) + if not peers_raw: + peers_raw = _suggest_peer_tickers(ticker, info or {}) + peers = [p for p in peers_raw[:8] if p.upper() != ticker.upper()] + peer_ratio_list = get_ratios_for_tickers(peers) if peers else [] + peer_bands = _compute_peer_bands(peer_ratio_list) + + growth = _compute_growth_ratios(ticker) + sparks = _build_hist_sparks(hist_rows) + + # Computed values + def _r(key): return ratios.get(key) if ratios else None + + pe = _r("peRatioTTM") or (info.get("trailingPE") if info else None) + pe_fwd = _r("forwardPE") or (info.get("forwardPE") if info else None) + ev_ebt = _r("enterpriseValueMultipleTTM") + ev_rev = _r("evToSalesTTM") + pb = _r("priceToBookRatioTTM") + ps = _r("priceToSalesRatioTTM") + fcf_yield_v = (fcf_ttm / market_cap) if fcf_ttm and market_cap and market_cap > 0 else None + p_fcf = (market_cap / fcf_ttm) if fcf_ttm and fcf_ttm > 0 and market_cap else None + gross_m = _r("grossProfitMarginTTM") + op_m = _r("operatingProfitMarginTTM") + net_m = _r("netProfitMarginTTM") + roe = _r("returnOnEquityTTM") + roa = _r("returnOnAssetsTTM") + roic = _r("returnOnInvestedCapitalTTM") + cur_r = _r("currentRatioTTM") + quick_r = _r("quickRatioTTM") + d_e = _r("debtToEquityRatioTTM") + coverage = _r("interestCoverageRatioTTM") + div_y = _r("dividendYieldTTM") + payout = _r("dividendPayoutRatioTTM") + ebitda = _r("ebitdaTTM") + cash_raw = None + net_debt_ebt = None + try: + bridge = get_balance_sheet_bridge_items(ticker) + cash_raw = bridge.get("cash_and_equivalents") + total_debt = bridge.get("total_debt") or 0 + if ebitda and ebitda > 0 and cash_raw is not None and total_debt is not None: + net_debt_ebt = (total_debt - cash_raw) / ebitda + if cash_raw and market_cap and market_cap > 0: + cash_mkt = cash_raw / market_cap + else: + cash_mkt = None + except Exception: + cash_mkt = None + net_debt_ebt = None + + # Price info + 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 = f"{'▲' if chg_pct >= 0 else '▼'} {'+' if chg_pct >= 0 else ''}{chg_pct:.2f}%" + chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg" + else: + chg_str, chg_cls = "", "chg-pos" + + _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 "—" + co_name = (info.get("longName", ticker) if info else ticker) or ticker + sector = (info.get("sector", "—") if info else "—") or "—" + industry = (info.get("industry", "—") if info else "—") or "—" + n_peers = len(peers) + from datetime import date as _date + today_str = _date.today().strftime("%b %d, %Y") + + # ── Helper: render a row in the mini detail cards ────────────────────── + def _mini_row(lbl, v, kind, sector_v, spark_data, invert=False, good_low=False): + fv = _fmtv(v, kind) + sv = _fmtv(sector_v, kind) + if v is not None and sector_v is not None: + try: + fv_f, sv_f = float(v), float(sector_v) + if kind == "%": + # Show absolute percentage-point difference (design: "+4.1pp") + diff_pp = (fv_f - sv_f) * 100 + tone = "flat" if abs(diff_pp) < 0.3 else ("neg" if (invert or good_low) == (diff_pp > 0) else "pos") + mini_cls = f'<span class="mini {tone}">{diff_pp:+.1f}pp</span>' + else: + diff = (fv_f - sv_f) / abs(sv_f) * 100 + tone = _tone(diff, invert or good_low) + mini_cls = f'<span class="mini {tone}">{diff:+.0f}%</span>' + sector_html = f'<span class="s num">{sv}{mini_cls}</span>' + except Exception: + sector_html = f'<span class="s num">{sv}</span>' + else: + sector_html = f'<span class="s num">{sv}</span>' + spark_color = "var(--positive)" if not (invert or good_low) else "var(--warning)" + spark_svg = _svg_spark(spark_data, 86, 20, spark_color) if spark_data else "" + return ( + f'<div class="kr-mini">' + f'<span class="lbl">{lbl}</span>' + f'<span class="v num">{fv}</span>' + f'{sector_html}' + f'<span class="r">{spark_svg}</span>' + f'</div>' + ) + + # ── Helper: build peer band section ──────────────────────────────────── + def _val_row(lbl, v, kind, field, five_avg, spark_data, invert=True): + fv = _fmtv(v, kind) + band = peer_bands.get(field, {}) + p25 = band.get("p25") + p50 = band.get("p50") + p75 = band.get("p75") + bmin = band.get("min") + bmax = band.get("max") + if v is not None and p50 is not None: + try: + diff = (float(v) - p50) / abs(p50) * 100 + tone = _tone(diff, invert) + d_str = f"{'+' if diff >= 0 else ''}{diff:.0f}%" + except Exception: + tone, d_str = "flat", "—" + else: + tone, d_str = "flat", "—" + if five_avg is not None and v is not None: + try: + d_avg = (float(v) - float(five_avg)) / abs(float(five_avg)) * 100 + avg_tone = _tone(d_avg, invert) + avg_html = ( + f'<span class="v dim num r">' + f'{_fmtv(five_avg, kind)}' + f'<span class="mini {avg_tone}">{d_avg:+.0f}%</span>' + f'</span>' + ) + except Exception: + avg_html = f'<span class="v dim num r">{_fmtv(five_avg, kind)}</span>' + else: + avg_html = f'<span class="v dim num r">{_fmtv(five_avg, kind)}</span>' + spark_color = "var(--negative)" if tone == "neg" else ("var(--positive)" if tone == "pos" else "var(--brass-bright)") + spark_svg = _svg_spark(spark_data, 108, 24, spark_color) if spark_data else "" + peer_bar = _peer_bar_html(v, p25, p50, p75, bmin, bmax) + peer_axis = "" + if p25 is not None: + peer_axis = ( + f'<div class="peer-axis">' + f'<span>{_fmtv(p25, kind)}</span>' + f'<span>{_fmtv(p50, kind)}</span>' + f'<span>{_fmtv(p75, kind)}</span>' + f'</div>' + ) + return ( + f'<div class="kr-rowgrid">' + f'<span class="lbl">{lbl}</span>' + f'<span class="v num r">{fv}</span>' + f'<span class="d {tone} r">{d_str}</span>' + f'<div class="peer-wrap">{peer_bar}{peer_axis}</div>' + f'{avg_html}' + f'{spark_svg}' + f'</div>' + ) + + # ── Snapshot KPIs ─────────────────────────────────────────────────────── + def _kpi(lbl, v, kind, field, invert=False): + fv = _fmtv(v, kind) + band = peer_bands.get(field, {}) + p50 = band.get("p50") + sect_str = _fmtv(p50, kind) if p50 is not None else "—" + if v is not None and p50 is not None: + try: + diff = (float(v) - p50) / abs(p50) * 100 + tone = _tone(diff, invert) + d_str = f"{'+' if diff >= 0 else ''}{diff:.0f}% vs peers" + except Exception: + tone, d_str = "flat", "—" + else: + tone, d_str = "flat", "—" + # Use historical data for sparkline when available + return tone, ( + f'<div class="kr-kpi">' + f'<div class="top"><span class="lbl">{lbl}</span></div>' + f'<span class="v num">{fv}</span>' + f'<div class="bot">' + f'<span class="sector num">peers {sect_str}</span>' + f'<span class="d {tone} num">{d_str}</span>' + f'</div>' + f'</div>' + ) + + def _kpi_spark(lbl, v, kind, field, spark_data, invert=False): + fv = _fmtv(v, kind) + band = peer_bands.get(field, {}) + p50 = band.get("p50") + sect_str = _fmtv(p50, kind) if p50 is not None else "—" + if v is not None and p50 is not None: + try: + diff = (float(v) - p50) / abs(p50) * 100 + tone = _tone(diff, invert) + d_str = f"{'+' if diff >= 0 else ''}{diff:.0f}% vs peers" + except Exception: + tone, d_str = "flat", "—" + else: + tone, d_str = "flat", "—" + spark_color = "var(--negative)" if tone == "neg" else ("var(--positive)" if tone == "pos" else "var(--brass-bright)") + spark_svg = _svg_spark(spark_data, 68, 22, spark_color) if spark_data else "" + return ( + f'<div class="kr-kpi">' + f'<div class="top"><span class="lbl">{lbl}</span>{spark_svg}</div>' + f'<span class="v num">{fv}</span>' + f'<div class="bot">' + f'<span class="sector num">peers {sect_str}</span>' + f'<span class="d {tone} num">{d_str}</span>' + f'</div>' + f'</div>' + ) + + # Peer-median for snapshot section headings (approximated from bands) + snap_html = ( + _kpi_spark("P / E", pe, "x", "peRatioTTM", sparks.get("pe"), invert=True) + + _kpi_spark("EV / EBITDA", ev_ebt, "x", "enterpriseValueMultipleTTM", sparks.get("evEbt"), invert=True) + + _kpi_spark("EV / Revenue", ev_rev, "x", "evToSalesTTM", None, invert=True) + + _kpi_spark("P / Book", pb, "x", "priceToBookRatioTTM", sparks.get("pb"), invert=True) + + _kpi_spark("P / FCF", p_fcf, "x", "peRatioTTM", None, invert=True) + + _kpi_spark("FCF Yield", fcf_yield_v, "%", "dividendYieldTTM", None, invert=False) + ) + + # ── Get 5-yr averages from historical rows ────────────────────────────── + def _hist_avg(field): + vals = [r.get(field) for r in hist_rows if r.get(field) is not None] + return float(np.mean(vals)) if vals else None + + pe_5avg = _hist_avg("peRatio") + pb_5avg = _hist_avg("priceToBookRatio") + ps_5avg = _hist_avg("priceToSalesRatio") + evEbt_5avg = _hist_avg("enterpriseValueMultiple") + gross_5avg = _hist_avg("grossProfitMargin") + op_5avg = _hist_avg("operatingProfitMargin") + net_5avg = _hist_avg("netProfitMargin") + roe_5avg = _hist_avg("returnOnEquity") + roa_5avg = _hist_avg("returnOnAssets") + de_5avg = _hist_avg("debtEquityRatio") + + # Peer medians for detail rows + def _pm(field): return peer_bands.get(field, {}).get("p50") + + # ── Assemble HTML ─────────────────────────────────────────────────────── + ctx_price = f'<span class="px num">${price:,.2f}</span>' if price else "" + ctx_chg = f'<span class="{chg_cls} num">{chg_str}</span>' if chg_str else "" + + val_rows_html = ( + _val_row("P / E · TTM", pe, "x", "peRatioTTM", pe_5avg, sparks.get("pe"), invert=True) + + _val_row("P / E · Forward", pe_fwd, "x", "forwardPE", None, None, invert=True) + + _val_row("EV / EBITDA", ev_ebt, "x", "enterpriseValueMultipleTTM", evEbt_5avg, sparks.get("evEbt"), invert=True) + + _val_row("EV / Revenue", ev_rev, "x", "evToSalesTTM", None, None, invert=True) + + _val_row("P / Book", pb, "x", "priceToBookRatioTTM", pb_5avg, sparks.get("pb"), invert=True) + + _val_row("P / Sales", ps, "x", "priceToSalesRatioTTM", ps_5avg, sparks.get("ps"), invert=True) + + _val_row("P / FCF", p_fcf, "x", "peRatioTTM", None, None, invert=True) + ) + + prof_rows_html = ( + '<div class="kr-mini head"><span>Metric</span><span>Subject</span><span>Peers + Δ</span><span class="r">Trend</span></div>' + + _mini_row("Gross margin", gross_m, "%", _pm("grossProfitMarginTTM"), sparks.get("gross")) + + _mini_row("Operating margin", op_m, "%", _pm("operatingProfitMarginTTM"), sparks.get("op")) + + _mini_row("Net margin", net_m, "%", _pm("netProfitMarginTTM"), sparks.get("net")) + + _mini_row("Return on equity", roe, "%", _pm("returnOnEquityTTM"), sparks.get("roe")) + + _mini_row("Return on assets", roa, "%", _pm("returnOnAssetsTTM"), sparks.get("roa")) + + _mini_row("Return on invested capital", roic, "%", _pm("returnOnInvestedCapitalTTM"), None) + ) + + growth_rows_html = ( + '<div class="kr-mini head"><span>Metric</span><span>Subject</span><span>Peers + Δ</span><span class="r">Trend</span></div>' + + _mini_row("Revenue · TTM YoY", growth.get("revYoY"), "%", _pm("revenueGrowthTTM"), None) + + _mini_row("Revenue · 3-yr CAGR", growth.get("rev3yrCAGR"), "%", None, None) + + _mini_row("EPS · TTM YoY", growth.get("epsYoY"), "%", _pm("earningsGrowthTTM"), None) + + _mini_row("FCF · TTM YoY", growth.get("fcfYoY"), "%", None, None) + + _mini_row("Operating income YoY",growth.get("opIncYoY"), "%", None, None) + + _mini_row("Diluted shares YoY", growth.get("sharesYoY"), "%", None, None, invert=True) + ) + + health_rows_html = ( + '<div class="kr-mini head"><span>Metric</span><span>Subject</span><span>Peers</span><span class="r">Trend</span></div>' + + _mini_row("Net debt / EBITDA", net_debt_ebt, "x", _pm("debtToEquityRatioTTM"), None, good_low=True) + + _mini_row("Total debt / Equity", d_e, "x", _pm("debtToEquityRatioTTM"), sparks.get("de"), good_low=True) + + _mini_row("Interest coverage", coverage, "x", _pm("interestCoverageRatioTTM"), None) + + _mini_row("Current ratio", cur_r, "x", _pm("currentRatioTTM"), None) + + _mini_row("Quick ratio", quick_r, "x", _pm("quickRatioTTM"), None) + + _mini_row("Cash / Market cap", cash_mkt, "%", None, None) + ) + + cash_rows_html = ( + '<div class="kr-mini head"><span>Metric</span><span>Subject</span><span>Peers</span><span class="r">Trend</span></div>' + + _mini_row("FCF yield", fcf_yield_v, "%", _pm("dividendYieldTTM"), None) + + _mini_row("Dividend yield", div_y, "%", _pm("dividendYieldTTM"), None) + + _mini_row("Payout ratio", payout, "%", _pm("dividendPayoutRatioTTM"), None, good_low=True) + + _mini_row("Buyback yield", growth.get("buybackYield"), "%", None, None) + ) + + body = ( + f'<div class="val-ctx">' + f'<span class="sym">{ticker.upper()}</span>' + f'<span class="name">{co_name}</span>' + f'<span class="eyebrow-ctx" style="margin-left:12px">Valuation · Key Ratios</span>' + f'<div class="meta"><span>{exchange}</span>{ctx_price}{ctx_chg}</div>' + f'</div>' + f'<div class="kr-body">' + f'<section class="kr-lede">' + f'<div class="left">' + f'<span class="eyebrow-lbl">Snapshot</span>' + f'<div class="ttl">Where the lens sits — six headline ratios, scored against the peer set</div>' + f'<p class="sub">TTM ratios, peer medians from {n_peers} peers ({sector}). Sparklines show historical drift; the peer band on each row is the 25th–75th percentile of the peer set.</p>' + f'</div>' + f'<div class="right">' + f'<div class="kr-source"><span class="lbl">Peer set</span><span class="v num">{n_peers} names</span><span class="cap">{industry[:28]}</span></div>' + f'<div class="kr-source"><span class="lbl">Basis</span><span class="v num">TTM</span><span class="cap">Trailing twelve months</span></div>' + f'<div class="kr-source"><span class="lbl">As of</span><span class="v num">{today_str}</span><span class="cap">Prices live · yfinance</span></div>' + f'</div>' + f'</section>' + f'<section class="kr-snapshot">{snap_html}</section>' + f'<section class="kr-card">' + f'<div class="kr-card-head"><div class="left-group"><span class="roman">I</span><h3>Valuation multiples</h3></div><span class="hint">Subject · Peer P25 / median / P75 · 5-yr drift</span></div>' + f'<div class="kr-rowgrid head"><span>Ratio</span><span class="r">Subject</span><span class="r">vs peers</span><span>Peer 25 — 75</span><span class="r">5-yr avg</span><span>5-yr trend</span></div>' + f'{val_rows_html}' + f'</section>' + f'<section class="kr-grid-2">' + f'<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">II</span><h3>Profitability</h3></div><span class="hint">Wider margins, higher returns on capital</span></div>{prof_rows_html}</div>' + f'<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">III</span><h3>Growth · TTM</h3></div><span class="hint">Topline & cash growth vs peers</span></div>{growth_rows_html}</div>' + f'<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">IV</span><h3>Balance-sheet health</h3></div><span class="hint">Leverage, liquidity, interest</span></div>{health_rows_html}</div>' + f'<div class="kr-card"><div class="kr-card-head"><div class="left-group"><span class="roman">V</span><h3>Cash returns</h3></div><span class="hint">Cash giveback to holders · yield</span></div>{cash_rows_html}</div>' + f'</section>' + f'<div class="va-foot"><span>Ratios computed from yfinance financial statements, TTM basis. Peer bands from {n_peers} comparable names. Market data live.</span></div>' + f'</div>' + ) + + doc = f"""<!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;0,600;1,400;1,500;1,600&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet"> +<style> +*,*::before,*::after{{box-sizing:border-box}} +:root{{ + --ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340; + --line-1:#232934;--line-2:#2E3645;--line-3:#3D4658; + --fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849; + --brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;--brass-ink:#17120A; + --oxford:#1F3D5C;--oxford-light:#2E5A87; + --positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517; + --warning:#C49545;--warning-bg:#2A1F0F; + --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-snug:-0.01em; + --sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px; + --r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px; + --shadow-1:0 1px 0 rgba(0,0,0,.4),0 1px 2px rgba(0,0,0,.3); +}} +html,body{{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}} +</style> +{_KR_CSS} +</head><body>{body}</body></html>""" + + components.html(doc, height=2400, scrolling=True) # ── Models ─────────────────────────────────────────────────────────────────── @@ -895,6 +1384,7 @@ def _build_dcf_canvas_html( hist_growth_str = f"{result['growth_rate_used']*100:+.1f}%" net_debt_str = _fmt_b(net_debt_raw) shares_str = f"{ctx['shares']/1e9:.2f} B" + net_debt_label = f"Net debt{(' · ' + source_date) if source_date else ''}" html = f"""<!DOCTYPE html> <html> @@ -927,6 +1417,11 @@ def _build_dcf_canvas_html( .dcf-rail input[type=range]{{width:100%;-webkit-appearance:none;appearance:none;background:var(--ink-3);height:4px;border-radius:999px;cursor:pointer;outline:none}} .dcf-rail input[type=range]::-webkit-slider-thumb{{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;box-shadow:0 0 0 1px var(--brass-deep);cursor:pointer}} .dcf-rail input[type=range]::-moz-range-thumb{{width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;cursor:pointer;border:none}} +.rail-sl-hint{{display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:10px;color:var(--fg-4);margin-top:3px;letter-spacing:.02em}} +.rail-actions{{display:flex;flex-direction:column;gap:8px;margin-top:16px}} +.rail-btn{{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);background:var(--ink-2);border:1px solid var(--line-2);border-radius:3px;padding:7px 12px;cursor:pointer;text-align:center;transition:color .15s,border-color .15s;width:100%}} +.rail-btn:hover{{color:var(--fg-1);border-color:var(--line-3)}} +.rail-btn[disabled]{{opacity:.4;cursor:not-allowed;pointer-events:none}} </style> </head> <body> @@ -944,6 +1439,7 @@ def _build_dcf_canvas_html( <span class="rail-sl-val" id="wacc-disp">{wacc_pct:.2f}%</span> </div> <input type="range" id="sl-wacc" min="4" max="15" step="0.25" value="{wacc_pct}"> + <div class="rail-sl-hint"><span>4.0 aggressive</span><span>conservative 15.0</span></div> </div> <div class="rail-sl-item"> <div class="rail-sl-head"> @@ -951,6 +1447,7 @@ def _build_dcf_canvas_html( <span class="rail-sl-val" id="tg-disp">{tg_pct:.1f}%</span> </div> <input type="range" id="sl-tg" min="0" max="5" step="0.1" value="{tg_pct}"> + <div class="rail-sl-hint"><span>0.0 conservative</span><span>aggressive 5.0</span></div> </div> <div class="rail-sl-item"> <div class="rail-sl-head"> @@ -958,6 +1455,7 @@ def _build_dcf_canvas_html( <span class="rail-sl-val" id="yrs-disp">{yrs} yr</span> </div> <input type="range" id="sl-yrs" min="3" max="10" step="1" value="{yrs}"> + <div class="rail-sl-hint"><span>3 yr short</span><span>extended 10 yr</span></div> </div> <div class="rail-sl-item"> <div class="rail-sl-head"> @@ -965,6 +1463,7 @@ def _build_dcf_canvas_html( <span class="rail-sl-val" id="g-disp">{g_pct:.1f}%</span> </div> <input type="range" id="sl-g" min="-15" max="20" step="0.1" value="{g_pct}"> + <div class="rail-sl-hint"><span>-15 decline</span><span>growth +20</span></div> </div> </div> <div class="rail-warn" id="wacc-tg-warn" style="display:none">WACC must exceed terminal growth</div> @@ -974,8 +1473,13 @@ def _build_dcf_canvas_html( <div class="dcf-filings-eyebrow">From the filings</div> <div class="dcf-filing-row"><span>Base FCF (TTM)</span><span class="dcf-filing-val">{base_fcf_str}</span></div> <div class="dcf-filing-row"><span>FCF · 5-yr median</span><span class="dcf-filing-val">{hist_growth_str}</span></div> - <div class="dcf-filing-row"><span>Net debt</span><span class="dcf-filing-val">{net_debt_str}</span></div> + <div class="dcf-filing-row"><span>{net_debt_label}</span><span class="dcf-filing-val">{net_debt_str}</span></div> <div class="dcf-filing-row"><span>Shares outstanding</span><span class="dcf-filing-val">{shares_str}</span></div> + + <div class="rail-actions"> + <button class="rail-btn" onclick="resetSliders()">Reset to defaults</button> + <button class="rail-btn" disabled>Save scenario · soon</button> + </div> </aside> <div class="dcf-canvas-inner"> @@ -1086,6 +1590,18 @@ def _build_dcf_canvas_html( <script> var D = {data_json}; var LAYOUT = {plotly_layout_json}; +var INIT_WACC = {wacc_pct}; +var INIT_TG = {tg_pct}; +var INIT_YRS = {yrs}; +var INIT_G = {g_pct}; + +function resetSliders() {{ + document.getElementById('sl-wacc').value = INIT_WACC; + document.getElementById('sl-tg').value = INIT_TG; + document.getElementById('sl-yrs').value = INIT_YRS; + document.getElementById('sl-g').value = INIT_G; + update(); +}} function fB(n) {{ var b=n/1e9; return Math.abs(b)>=1000?'$'+(b/1000).toFixed(2)+'T':'$'+b.toFixed(2)+'B'; }} function fS(n) {{ return '$'+n.toLocaleString('en-US',{{minimumFractionDigits:2,maximumFractionDigits:2}}); }} @@ -1726,11 +2242,6 @@ def _render_dcf_model(ctx: dict): st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True) - if st.button("Recompute", key=f"dcf_recompute_{ctx['ticker']}", type="primary"): - get_free_cash_flow_ttm.clear() - get_balance_sheet_bridge_items.clear() - st.rerun() - # Read assumptions from session state (set by in-canvas JS sliders, defaulting on first load) wacc_pct = float(st.session_state.get(f"dcf_wacc_{ctx['ticker']}", 10.0)) tg_pct = float(st.session_state.get(f"dcf_tg_{ctx['ticker']}", 2.5)) @@ -1803,6 +2314,11 @@ def _render_dcf_model(ctx: dict): components.html(canvas_html, height=1620, scrolling=False) + if st.button("Recompute", key=f"dcf_recompute_{ctx['ticker']}", type="secondary"): + get_free_cash_flow_ttm.clear() + get_balance_sheet_bridge_items.clear() + st.rerun() + def _render_multiples_model(ctx: dict): st.markdown(_DCF_RAIL_CSS, unsafe_allow_html=True) @@ -1834,7 +2350,7 @@ def _render_multiples_model(ctx: dict): st.markdown('<hr class="dcf-divider">', unsafe_allow_html=True) - if st.button("Refresh data", key=f"mult_refresh_{ctx['ticker']}", type="primary", use_container_width=True): + if st.button("Refresh data", key=f"mult_refresh_{ctx['ticker']}", type="primary", width="stretch"): get_balance_sheet_bridge_items.clear() st.rerun() @@ -1953,7 +2469,7 @@ def _render_ev_ebitda_model(ctx: dict): "What it means": "Equity value divided by shares outstanding.", }, ] - st.dataframe(pd.DataFrame(summary_rows), use_container_width=True, hide_index=True) + st.dataframe(pd.DataFrame(summary_rows), width="stretch", hide_index=True) st.markdown("**EV/EBITDA Conclusion**") ev_m1, ev_m2, ev_m3, ev_m4 = st.columns(4) @@ -2093,7 +2609,7 @@ def _render_ev_revenue_model(ctx: dict): "What it means": "Equity value divided by shares outstanding.", }, ] - st.dataframe(pd.DataFrame(summary_rows), use_container_width=True, hide_index=True) + st.dataframe(pd.DataFrame(summary_rows), width="stretch", hide_index=True) st.markdown("**EV/Revenue Conclusion**") evr_m1, evr_m2, evr_m3, evr_m4 = st.columns(4) @@ -2199,7 +2715,7 @@ def _render_models(ticker: str): "Discounted Cash Flow", key=f"pick_dcf_{ticker}", type="primary" if st.session_state["models_view"] == "dcf" else "secondary", - use_container_width=True, + width="stretch", ): st.session_state["models_view"] = "dcf" st.rerun() @@ -2208,7 +2724,7 @@ def _render_models(ticker: str): "Multiples", key=f"pick_mult_{ticker}", type="primary" if st.session_state["models_view"] == "multiples" else "secondary", - use_container_width=True, + width="stretch", ): st.session_state["models_view"] = "multiples" st.rerun() @@ -2332,7 +2848,7 @@ def _render_comps(ticker: str): st.dataframe( df.style.apply(highlight_subject, axis=1), - use_container_width=True, + width="stretch", hide_index=True, ) @@ -2405,7 +2921,7 @@ def _render_analyst_targets(ticker: str): margin=dict(l=0, r=0, t=40, b=0), height=280, ) - st.plotly_chart(fig, use_container_width=True) + st.plotly_chart(fig, width="stretch") # ── Earnings History ────────────────────────────────────────────────────────── @@ -2449,7 +2965,7 @@ def _render_earnings_history(ticker: str): st.dataframe( display.style.apply(highlight_surprise, axis=1), - use_container_width=True, + width="stretch", hide_index=False, ) st.download_button( @@ -2486,7 +3002,7 @@ def _render_earnings_history(ticker: str): height=280, legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), ) - st.plotly_chart(fig, use_container_width=True) + st.plotly_chart(fig, width="stretch") # ── Historical Ratios ──────────────────────────────────────────────────────── @@ -2526,100 +3042,338 @@ def _extract_hist_series(rows: list[dict], primary: str, alt: str | None) -> dic return out -def _render_historical_ratios(ticker: str): - with st.spinner("Loading historical ratios…"): - ratio_rows = get_historical_ratios(ticker) - metric_rows = get_historical_key_metrics(ticker) +_KH_CSS = """<style> +.kh-body{padding:var(--sp-5) var(--sp-6) var(--sp-7);display:flex;flex-direction:column;gap:var(--sp-5);flex:1} +.kh-lede{display:grid;grid-template-columns:1.4fr 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)} +.kh-lede .left{display:flex;flex-direction:column;gap:8px} +.kh-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} +.kh-lede .sub{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);line-height:1.55;max-width:60ch} +.kh-lede .right{display:flex;flex-direction:column;gap:var(--sp-3);align-self:end;align-items:flex-end} +.kh-legend{display:flex;gap:var(--sp-4);font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-2);align-items:center} +.kh-legend>span{white-space:nowrap} +.kh-legend .sw{display:inline-block;width:18px;height:3px;border-radius:999px;vertical-align:middle;margin-right:6px} +.kh-legend .sw.subj{background:var(--brass-bright)} +.kh-window{display:flex;align-items:center;gap:var(--sp-3)} +.kh-window .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600} +.kh-window .seg{display:inline-flex;gap:2px;padding:2px;border:1px solid var(--line-2);background:var(--ink-2);border-radius:var(--r-1)} +.kh-window .seg button{font-family:var(--font-mono);font-size:var(--fs-12);background:transparent;border:none;color:var(--fg-3);padding:4px 10px;cursor:pointer;border-radius:var(--r-1);white-space:nowrap} +.kh-window .seg button.active{background:var(--ink-3);color:var(--fg-1);box-shadow:inset 0 0 0 1px var(--line-3)} +.kh-hero{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.kh-hero-head{padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--line-1);display:grid;grid-template-columns:1fr auto;align-items:center;gap:var(--sp-5)} +.kh-hero-head .left{display:flex;flex-direction:column;gap:2px} +.kh-hero-head h3{font-family:var(--font-display);font-size:var(--fs-24);font-weight:500;margin:0;letter-spacing:-0.01em;color:var(--fg-1)} +.kh-hero-head h3 .kind{font-family:var(--font-display);font-style:italic;font-weight:400;color:var(--fg-3);font-size:var(--fs-18)} +.kh-stats{display:flex;gap:var(--sp-5)} +.kh-stats .cell{display:flex;flex-direction:column;gap:2px} +.kh-stats .cell .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600;white-space:nowrap} +.kh-stats .cell .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-18);color:var(--fg-1);font-weight:500} +.kh-stats .cell .d{font-family:var(--font-mono);font-size:11px} +.kh-stats .cell .d.pos{color:var(--positive)}.kh-stats .cell .d.neg{color:var(--negative)} +.kh-chart-wrap{padding:var(--sp-4) var(--sp-5);background:linear-gradient(180deg,transparent 0%,rgba(194,170,122,0.02) 100%)} +.kh-chart-svg{display:block;width:100%;height:300px} +.kh-matrix{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.kh-matrix-head{padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--line-1);display:flex;justify-content:space-between;align-items:baseline} +.kh-matrix-head h3{font-family:var(--font-display);font-size:var(--fs-20);font-weight:500;margin:0;color:var(--fg-1)} +.kh-matrix-head .hint{font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)} +.kh-matrix-grid{display:grid;align-items:center;border-bottom:1px solid var(--line-1);cursor:pointer;transition:background .08s ease} +.kh-matrix-grid:last-child{border-bottom:none} +.kh-matrix-grid:hover{background:rgba(194,170,122,0.04)} +.kh-matrix-grid.active{background:rgba(194,170,122,0.08);box-shadow:inset 3px 0 0 var(--brass)} +.kh-matrix-grid.head{background:var(--ink-2);font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600;cursor:default} +.kh-matrix-grid.head:hover{background:var(--ink-2)} +.kh-matrix-grid.head span{padding:8px var(--sp-3)} +.kh-matrix-grid>.lbl,.kh-matrix-grid>.cell{padding:9px var(--sp-3)} +.kh-matrix-grid>.lbl{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-1);padding-left:var(--sp-5)} +.kh-matrix-grid .cell{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-13);color:var(--fg-2);text-align:right} +.kh-matrix-grid .cell.last{color:var(--fg-1);font-weight:600} +.kh-matrix-section{padding:14px var(--sp-5) 6px;font-family:var(--font-display);font-style:italic;font-size:var(--fs-16);color:var(--brass);background:var(--ink-2);border-bottom:1px solid var(--line-1);font-weight:400;letter-spacing:-0.01em} +</style>""" + - if not ratio_rows and not metric_rows: +def _render_historical_ratios(ticker: str): + import json as _json + info = get_company_info(ticker) + hist_rows = get_historical_ratios(ticker, limit=10) + if not hist_rows: st.info("Historical ratio data unavailable.") return - - # Merge both lists by date - combined: dict[str, dict] = {} - for row in ratio_rows + metric_rows: - date = str(row.get("date", ""))[:4] - if date: - combined.setdefault(date, {}).update(row) - - merged_rows = [{"date": d, **v} for d, v in sorted(combined.items(), reverse=True)] - - selected = st.multiselect( - "Metrics to plot", - options=list(_HIST_RATIO_OPTIONS.keys()), - default=["P/E", "EV/EBITDA", "Net Margin", "ROE"], - ) - - if not selected: - st.info("Select at least one metric to plot.") + rows_sorted = sorted(hist_rows, key=lambda r: str(r.get("date", ""))) + periods = [] + for r in rows_sorted: + y = str(r.get("date", ""))[:4] + periods.append(f"FY{y[2:]}" if len(y) == 4 else y) + SERIES_DEFS = [ + ("pe", "Valuation", "P / E", "x", "peRatio"), + ("evebt", "Valuation", "EV / EBITDA", "x", "enterpriseValueMultiple"), + ("pb", "Valuation", "P / Book", "x", "priceToBookRatio"), + ("ps", "Valuation", "P / Sales", "x", "priceToSalesRatio"), + ("gm", "Profitability", "Gross margin", "%", "grossProfitMargin"), + ("om", "Profitability", "Operating margin", "%", "operatingProfitMargin"), + ("nm", "Profitability", "Net margin", "%", "netProfitMargin"), + ("roe", "Profitability", "Return on equity", "%", "returnOnEquity"), + ("roa", "Profitability", "Return on assets", "%", "returnOnAssets"), + ("de", "Health", "Debt / Equity", "x", "debtEquityRatio"), + ] + series_data = [] + for key, group, lbl, kind, field in SERIES_DEFS: + vals = [] + for r in rows_sorted: + v = r.get(field) + if v is not None: + try: + fv = float(v) + vals.append(round(fv * 100, 4) if kind == "%" else round(fv, 4)) + except (TypeError, ValueError): + vals.append(None) + else: + vals.append(None) + if len([v for v in vals if v is not None]) >= 2: + series_data.append({"key": key, "group": group, "lbl": lbl, "kind": kind, "subj": vals}) + if not series_data: + st.info("No plottable ratio data available.") return - - fig = go.Figure() - for i, label in enumerate(selected): - primary, alt, fmt = _HIST_RATIO_OPTIONS[label] - series = _extract_hist_series(merged_rows, primary, alt) - if not series: - continue - years = sorted(series.keys()) - values = [series[y] * (100 if fmt == "pct" else 1) for y in years] - y_label = f"{label} (%)" if fmt == "pct" else label - fig.add_trace(go.Scatter( - x=years, - y=values, - name=y_label, - mode="lines+markers", - line=dict(color=_CHART_COLORS[i % len(_CHART_COLORS)], width=2), - )) - - fig.update_layout( - title="Historical Ratios & Metrics", - xaxis_title="Year", - 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=380, - legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), - hovermode="x unified", + 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 = f"{'▲' if chg_pct >= 0 else '▼'} {'+' if chg_pct >= 0 else ''}{chg_pct:.2f}%" + chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg" + else: + chg_str, chg_cls = "—", "" + sym = ticker.upper() + name = (info.get("longName") or info.get("shortName") or sym) if info else sym + _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 "—" + n_periods = len(periods) + n_rows = len(series_data) + n_groups = len({s["group"] for s in series_data}) + total_height = 48 + 24 + 180 + 24 + 420 + 24 + (68 + n_groups * 50 + n_rows * 42) + 24 + 60 + 60 + data_json = _json.dumps({"periods": periods, "series": series_data}) + ctx_html = ( + f'<div class="val-ctx">' + f'<span class="sym">{sym}</span>' + f'<span class="name">{name}</span>' + f'<span class="eyebrow-ctx" style="margin-left:12px">Valuation · Historical Ratios</span>' + f'<div class="meta">' + f'<span>{exchange}</span>' + f'<span class="px num">{price_str}</span>' + f'<span class="{chg_cls} num">{chg_str}</span>' + f'</div></div>' ) - st.plotly_chart(fig, use_container_width=True) - - # Raw data table - with st.expander("Raw data"): - display_cols = {} - for label in selected: - primary, alt, fmt = _HIST_RATIO_OPTIONS[label] - display_cols[label] = (primary, alt, fmt) - - def _format_hist_value(label: str, value, fmt: str | None) -> str: - if value is None: - return "—" - try: - v = float(value) - except (TypeError, ValueError): - return "—" - - if fmt == "pct": - return f"{v * 100:.2f}%" - if label == "P/E": - return f"{v:.2f}x" if v > 0 else "N/M (neg. earnings)" - if label == "EV/EBITDA": - return f"{v:.2f}x" if v > 0 else "N/M (neg. EBITDA)" - if label == "P/B": - return f"{v:.2f}x" if v > 0 else "N/M (neg. equity)" - if label == "Debt/Equity": - return f"{v:.2f}x" if v >= 0 else "N/M (neg. equity)" - return f"{v:.2f}x" if v > 0 else "—" - - table_rows = [] - for row in merged_rows: - r: dict = {"Year": str(row.get("date", ""))[:4]} - for label, (primary, alt, fmt) in display_cols.items(): - val = row.get(primary) or (row.get(alt) if alt else None) - r[label] = _format_hist_value(label, val, fmt) - table_rows.append(r) - - if table_rows: - st.dataframe(pd.DataFrame(table_rows), use_container_width=True, hide_index=True) + lede_html = ( + f'<section class="kh-lede">' + f'<div class="left">' + f'<span class="eyebrow-lbl">Drift</span>' + f'<h2 class="ttl">{n_periods} periods of every ratio — pick a line, the heatmap follows</h2>' + f'<p class="sub">Annual ratios from {periods[0]} through {periods[-1]}. ' + f'Click any row in the matrix to plot it in the hero chart above. ' + f'Cell shading shows each ratio's relative position within its own history.</p>' + f'</div>' + f'<div class="right">' + f'<div class="kh-legend">' + f'<span><span class="sw subj"></span>{sym}</span>' + f'</div>' + f'<div class="kh-window">' + f'<span class="lbl">Window</span>' + f'<div class="seg">' + f'<button onclick="setWindow({n_periods},this)" class="active">All</button>' + f'<button onclick="setWindow(5,this)">5 yr</button>' + f'<button onclick="setWindow(3,this)">3 yr</button>' + f'</div>' + f'</div>' + f'</div>' + f'</section>' + ) + hero_html = ( + '<section class="kh-hero">' + '<div class="kh-hero-head">' + '<div class="left">' + '<span class="eyebrow-lbl" id="kh-hero-group"></span>' + '<h3 id="kh-hero-title"></h3>' + '</div>' + '<div class="kh-stats">' + '<div class="cell"><span class="lbl">Latest</span><span class="v num" id="kh-stat-latest">—</span></div>' + '<div class="cell"><span class="lbl" id="kh-stat-n-lbl">Avg</span><span class="v num" id="kh-stat-avg">—</span><span class="d num" id="kh-stat-davg"></span></div>' + '<div class="cell"><span class="lbl">Range</span><span class="v num" id="kh-stat-range">—</span></div>' + '</div>' + '</div>' + '<div class="kh-chart-wrap"><div id="kh-chart"></div></div>' + '</section>' + ) + matrix_html = ( + '<section class="kh-matrix">' + '<div class="kh-matrix-head">' + '<h3>Ratio matrix</h3>' + '<span class="hint">Click a row to chart it · shading shows relative position within row history</span>' + '</div>' + '<div class="kh-matrix-grid head" id="kh-matrix-head-row"></div>' + '<div id="kh-matrix-body"></div>' + '</section>' + ) + foot_html = ( + '<div class="va-foot">' + '<span>Ratios computed from yfinance annual income statements, balance sheets, and 10-year price history. ' + 'Price-based multiples use average price in a ±45-day window around each fiscal year-end.</span>' + '</div>' + ) + body = ctx_html + '<div class="kh-body">' + lede_html + hero_html + matrix_html + foot_html + '</div>' + js = ( + "const DATA=" + data_json + ";\n" + "const PERIODS=DATA.periods;\n" + "const SERIES=DATA.series;\n" + "let selKey=SERIES[0].key;\n" + "let winLen=PERIODS.length;\n" + "function getSlice(){\n" + " const n=Math.min(winLen,PERIODS.length);\n" + " return{periods:PERIODS.slice(-n),series:SERIES.map(s=>({...s,subj:s.subj.slice(-n)}))};\n" + "}\n" + "function fmtV(v,kind){\n" + " if(v===null||v===undefined||isNaN(v))return'—';\n" + " if(kind==='%')return v.toFixed(1)+'%';\n" + " return v.toFixed(1)+'×';\n" + "}\n" + "function heatTone(v,arr){\n" + " const clean=arr.filter(x=>x!==null&&!isNaN(x));\n" + " if(clean.length<2)return'';\n" + " const mn=Math.min(...clean),mx=Math.max(...clean);\n" + " const t=(v-mn)/((mx-mn)||1);\n" + " const a=(0.04+t*0.32).toFixed(3);\n" + " return'rgba(194,170,122,'+a+')';\n" + "}\n" + "function drawChart(){\n" + " const{periods,series}=getSlice();\n" + " const s=series.find(x=>x.key===selKey)||series[0];\n" + " const subj=s.subj;\n" + " const W=1100,H=300,Pl=60,Pr=24,Pt=24,Pb=36;\n" + " const clean=subj.filter(x=>x!==null);\n" + " if(!clean.length)return;\n" + " let yMn=Math.min(...clean),yMx=Math.max(...clean);\n" + " const pad=(yMx-yMn)*0.14||1;\n" + " yMn-=pad;yMx+=pad;\n" + " if(yMn>0&&yMn<pad*2)yMn=0;\n" + " const xAt=i=>Pl+(i/Math.max(periods.length-1,1))*(W-Pl-Pr);\n" + " const yAt=v=>Pt+(1-(v-yMn)/(yMx-yMn))*(H-Pt-Pb);\n" + " const pts=subj.map((v,i)=>({x:xAt(i),y:v!==null?yAt(v):null,v}));\n" + " let segs=[],cur=[];\n" + " pts.forEach(p=>{if(p.y!==null){cur.push(p);}else{if(cur.length){segs.push(cur);cur=[];}}})\n" + " if(cur.length)segs.push(cur);\n" + " const lp=segs.map(seg=>seg.map((p,i)=>(i===0?'M':'L')+p.x.toFixed(1)+' '+p.y.toFixed(1)).join(' ')).join(' ');\n" + " const fp=pts.find(p=>p.y!==null);\n" + " const lsP=[...pts].reverse().find(p=>p.y!==null);\n" + " const ap=fp&&lsP&&lp?lp+' L'+lsP.x.toFixed(1)+' '+(H-Pb)+' L'+fp.x.toFixed(1)+' '+(H-Pb)+' Z':'';\n" + " const ticks=[];\n" + " for(let i=0;i<5;i++)ticks.push(yMn+(yMx-yMn)*(i/4));\n" + " let svg='<defs><linearGradient id=\"kh-grad\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">';\n" + " svg+='<stop offset=\"0%\" stop-color=\"var(--brass)\" stop-opacity=\"0.18\"/>';\n" + " svg+='<stop offset=\"100%\" stop-color=\"var(--brass)\" stop-opacity=\"0\"/>';\n" + " svg+='</linearGradient></defs>';\n" + " ticks.forEach(t=>{\n" + " const y=yAt(t).toFixed(1);\n" + " svg+='<line x1=\"'+Pl+'\" x2=\"'+(W-Pr)+'\" y1=\"'+y+'\" y2=\"'+y+'\" stroke=\"var(--line-1)\" stroke-width=\"1\"/>';\n" + " svg+='<text x=\"'+(Pl-8)+'\" y=\"'+(parseFloat(y)+3).toFixed(1)+'\" font-family=\"var(--font-mono)\" font-size=\"10\" fill=\"var(--fg-3)\" text-anchor=\"end\">'+fmtV(t,s.kind)+'</text>';\n" + " });\n" + " periods.forEach((p,i)=>{\n" + " svg+='<text x=\"'+xAt(i).toFixed(1)+'\" y=\"'+(H-12)+'\" font-family=\"var(--font-mono)\" font-size=\"11\" fill=\"var(--fg-3)\" text-anchor=\"middle\">'+p+'</text>';\n" + " });\n" + " if(ap)svg+='<path d=\"'+ap+'\" fill=\"url(#kh-grad)\"/>';\n" + " if(lp)svg+='<path d=\"'+lp+'\" stroke=\"var(--brass-bright)\" stroke-width=\"2\" fill=\"none\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/>';\n" + " let lastVI=-1;\n" + " for(let k=pts.length-1;k>=0;k--){if(pts[k].y!==null){lastVI=k;break;}}\n" + " pts.forEach((p,idx)=>{\n" + " if(p.y===null)return;\n" + " svg+='<circle cx=\"'+p.x.toFixed(1)+'\" cy=\"'+p.y.toFixed(1)+'\" r=\"3\" fill=\"var(--brass-bright)\" stroke=\"var(--ink-1)\" stroke-width=\"1.5\"/>';\n" + " if(idx===lastVI)svg+='<text x=\"'+p.x.toFixed(1)+'\" y=\"'+(p.y-10).toFixed(1)+'\" font-family=\"var(--font-mono)\" font-size=\"11\" fill=\"var(--fg-1)\" text-anchor=\"end\" font-weight=\"500\">'+fmtV(p.v,s.kind)+'</text>';\n" + " });\n" + " document.getElementById('kh-chart').innerHTML='<svg viewBox=\"0 0 '+W+' '+H+'\" class=\"kh-chart-svg\" preserveAspectRatio=\"none\">'+svg+'</svg>';\n" + " const nonNull=subj.filter(x=>x!==null);\n" + " const latest=nonNull[nonNull.length-1];\n" + " const avg=nonNull.reduce((a,b)=>a+b,0)/nonNull.length;\n" + " const hi=Math.max(...nonNull),lo=Math.min(...nonNull);\n" + " const dAvg=avg!==0?((latest-avg)/Math.abs(avg))*100:0;\n" + " const n=periods.length;\n" + " document.getElementById('kh-hero-group').textContent=s.group;\n" + " document.getElementById('kh-hero-title').innerHTML=s.lbl+'<span class=\"kind\"> · '+(s.kind==='%'?'percent':'multiple')+'</span>';\n" + " document.getElementById('kh-stat-latest').textContent=fmtV(latest,s.kind);\n" + " document.getElementById('kh-stat-n-lbl').textContent=n+'-yr avg';\n" + " document.getElementById('kh-stat-avg').textContent=fmtV(avg,s.kind);\n" + " const davgEl=document.getElementById('kh-stat-davg');\n" + " davgEl.textContent=(dAvg>=0?'+':'')+dAvg.toFixed(0)+'%';\n" + " davgEl.className='d num '+(dAvg>=0?'pos':'neg');\n" + " document.getElementById('kh-stat-range').textContent=fmtV(lo,s.kind)+' — '+fmtV(hi,s.kind);\n" + "}\n" + "function renderMatrix(){\n" + " const{periods,series}=getSlice();\n" + " const n=periods.length;\n" + " const col='1.6fr '+'1fr '.repeat(n);\n" + " const headRow=document.getElementById('kh-matrix-head-row');\n" + " headRow.style.gridTemplateColumns=col;\n" + " let hh='<span class=\"lbl\" style=\"padding-left:var(--sp-5)\">Ratio</span>';\n" + " periods.forEach(p=>{hh+='<span class=\"r num\" style=\"text-align:right;padding:8px var(--sp-3)\">'+p+'</span>';});\n" + " headRow.innerHTML=hh;\n" + " const groups=[...new Set(series.map(s=>s.group))];\n" + " let html='';\n" + " groups.forEach(group=>{\n" + " html+='<div class=\"kh-matrix-section\">'+group+'</div>';\n" + " series.filter(s=>s.group===group).forEach(s=>{\n" + " const act=s.key===selKey?' active':'';\n" + " html+='<div class=\"kh-matrix-grid'+act+'\" style=\"grid-template-columns:'+col+'\" onclick=\"selectSeries(\\''+s.key+'\\')\">';\n" + " html+='<span class=\"lbl\">'+s.lbl+'</span>';\n" + " s.subj.forEach((v,i)=>{\n" + " const last=i===n-1?' last':'';\n" + " const bg=v!==null?' style=\"background:'+heatTone(v,s.subj)+'\"':'';\n" + " html+='<span class=\"cell num'+last+'\"'+bg+'>'+(v!==null?fmtV(v,s.kind):'—')+'</span>';\n" + " });\n" + " html+='</div>';\n" + " });\n" + " });\n" + " document.getElementById('kh-matrix-body').innerHTML=html;\n" + "}\n" + "function selectSeries(key){\n" + " selKey=key;\n" + " drawChart();\n" + " renderMatrix();\n" + "}\n" + "function setWindow(n,btn){\n" + " winLen=n;\n" + " document.querySelectorAll('.seg button').forEach(b=>b.classList.remove('active'));\n" + " btn.classList.add('active');\n" + " drawChart();\n" + " renderMatrix();\n" + "}\n" + "drawChart();\n" + "renderMatrix();\n" + ) + doc = ( + "<!doctype html><html><head><meta charset=\"utf-8\">" + "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">" + "<link href=\"https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=IBM+Plex+Mono:wght@300;400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap\" rel=\"stylesheet\">" + "<style>*,*::before,*::after{box-sizing:border-box}" + ":root{" + "--ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340;" + "--line-1:#232934;--line-2:#2E3645;--line-3:#3D4658;" + "--fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849;" + "--brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;" + "--oxford:#1F3D5C;--oxford-light:#2E5A87;" + "--positive:#4F8C5E;--negative:#B5494B;" + "--font-display:'EB Garamond',Georgia,serif;" + "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;" + "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;" + "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;" + "--tr-wider:0.12em;--tr-wide:0.04em;--tr-snug:-0.01em;" + "--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;" + "--r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px;" + "}" + "html,body{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}" + "</style>" + + _KR_CSS + _KH_CSS + + "</head><body>" + + body + + "<script>" + js + "</script>" + + "</body></html>" + ) + components.html(doc, height=total_height, scrolling=True) # ── Forward Estimates ──────────────────────────────────────────────────────── @@ -2727,12 +3481,12 @@ def _render_forward_estimates(ticker: str): hovermode="x unified", legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), ) - st.plotly_chart(fig, use_container_width=True) + st.plotly_chart(fig, width="stretch") with tab_ann: if annual: df = _build_estimates_table(annual) - st.dataframe(df, use_container_width=True, hide_index=True) + st.dataframe(df, width="stretch", hide_index=True) st.write("") _render_eps_chart(annual, "Annual EPS: Historical Actuals + Forward Estimates") else: @@ -2741,7 +3495,7 @@ def _render_forward_estimates(ticker: str): with tab_qtr: if quarterly: df = _build_estimates_table(quarterly) - st.dataframe(df, use_container_width=True, hide_index=True) + st.dataframe(df, width="stretch", hide_index=True) st.write("") _render_eps_chart(quarterly, "Quarterly EPS: Historical Actuals + Forward Estimates") else: |
