aboutsummaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/financials.py6
-rw-r--r--components/insiders.py4
-rw-r--r--components/options.py8
-rw-r--r--components/overview.py4
-rw-r--r--components/top_movers.py2
-rw-r--r--components/valuation.py1220
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 &amp; 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 &middot; 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&#39;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: