diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-05-15 19:10:37 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-05-15 19:10:37 -0700 |
| commit | a3771ecb46e81bb9d996da1e850ec9e4b9ed3c76 (patch) | |
| tree | 567eee9a30e2312ac624088e7eb318f97fb56a11 | |
| parent | fd1482b11c2d38a3c8aae52ae47d7709a2787399 (diff) | |
Redesigned Financials tab with components.html() dark-terminal aesthetic
Replaces st.radio/st.tabs/st.expander/st.dataframe with a single
components.html() panel matching the Prism design system — same token
block, val-ctx bar, lede card, and CSS grid tables as the Valuation tabs.
Annual/Quarterly toggle and Income/Balance Sheet/Cash Flow tabs are
handled in-iframe via vanilla JS with no Streamlit re-renders.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | components/financials.py | 478 |
1 files changed, 339 insertions, 139 deletions
diff --git a/components/financials.py b/components/financials.py index c01f8dc..9384e84 100644 --- a/components/financials.py +++ b/components/financials.py @@ -1,11 +1,16 @@ -"""Financial statements — organized and raw views for Income Statement, Balance Sheet, Cash Flow.""" +"""Financial statements — Income Statement, Balance Sheet, Cash Flow.""" import pandas as pd -import streamlit as st -from services.data_service import get_income_statement, get_balance_sheet, get_cash_flow, get_balance_sheet_bridge_items +import streamlit.components.v1 as components +from services.data_service import ( + get_income_statement, + get_balance_sheet, + get_cash_flow, + get_balance_sheet_bridge_items, + get_company_info, + get_latest_price, +) from utils.formatters import fmt_large -# Rows where an increase is bad (decline = green, increase = red). -# Labels must match yfinance row names (case-insensitive after .strip().lower()). _INVERSE_ROWS = { # ── Income statement ────────────────────────────────────────────────────── "cost of revenue", @@ -60,7 +65,6 @@ _INVERSE_ROWS = { "common stock payments", } - INCOME_GROUPS = { "Revenue": [ "Total Revenue", @@ -178,6 +182,12 @@ CASHFLOW_GROUPS = { ], } +_XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} + + +def _esc(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") + def _is_inverse(label: str) -> bool: return label.strip().lower() in _INVERSE_ROWS @@ -188,84 +198,133 @@ def _fmt_cell(value) -> str: v = float(value) except (TypeError, ValueError): return "—" + if v != v: + return "—" return fmt_large(v) -def _yoy_raw(current, previous): +def _yoy_pct(current, previous): try: - c, p = float(current), float(previous) - if p == 0: - return None - return (c - p) / abs(p) * 100 + c = float(current) + p = float(previous) except (TypeError, ValueError): return None + if c != c or p != p or p == 0: + return None + return (c - p) / abs(p) * 100 -def _yoy_str(pct) -> str: +def _yoy_html(pct, inverse: bool) -> str: if pct is None: - return "—" + return '<span style="color:var(--fg-4)">—</span>' arrow = "▲" if pct >= 0 else "▼" - return f"{arrow} {abs(pct):.1f}%" - - -def _build_statement(df: pd.DataFrame): - df = df.copy() - df.columns = [str(c)[:10] for c in df.columns] - cols = list(df.columns) - - display = pd.DataFrame(index=df.index) - colors = pd.DataFrame(index=df.index) + pct_str = str(round(abs(pct), 1)) + "%" + if abs(pct) < 1.0: + return ( + '<span style="color:var(--fg-3);font-family:var(--font-mono);' + 'font-size:11px;font-variant-numeric:tabular-nums">' + + arrow + " " + pct_str + + '</span>' + ) + positive = pct > 0 + good = positive if not inverse else not positive + color = "var(--positive)" if good else "var(--negative)" + bg = "var(--positive-bg)" if good else "var(--negative-bg)" + return ( + '<span style="color:' + color + ';background:' + bg + ';' + 'font-family:var(--font-mono);font-size:11px;font-variant-numeric:tabular-nums;' + 'padding:2px 6px;border-radius:var(--r-1);white-space:nowrap">' + + arrow + " " + pct_str + + '</span>' + ) - for i, col in enumerate(cols): - display[col] = df[col].apply(_fmt_cell) - colors[col] = "" - if i + 1 < len(cols): - prev_col = cols[i + 1] - yoy_label = f"YoY {col[:4]}" - raw_yoy = df.apply(lambda row: _yoy_raw(row[col], row[prev_col]), axis=1) - display[yoy_label] = raw_yoy.apply(_yoy_str) +def _build_table_html(df, groups: dict, stmt_id: str, n_periods: int, display_none: bool) -> str: + display_style = "display:none" if display_none else "display:block" + if df is None or df.empty: + return ( + '<div id="' + stmt_id + '" class="fs-stmt" style="' + display_style + '">' + '<div class="fs-empty">Data unavailable for this statement.</div>' + '</div>' + ) - def cell_color(row_label, pct): - if pct is None: - return "" - inverse = _is_inverse(row_label) - positive_change = pct >= 0 - good = positive_change if not inverse else not positive_change - return "green" if good else "red" + cols = list(df.columns[:n_periods]) + n_cols = len(cols) + if n_cols == 0: + return ( + '<div id="' + stmt_id + '" class="fs-stmt" style="' + display_style + '">' + '<div class="fs-empty">No periods available.</div>' + '</div>' + ) - colors[yoy_label] = pd.Series( - [cell_color(idx, pct) for idx, pct in zip(df.index, raw_yoy)], - index=df.index, - ) + year_labels = [str(c)[:4] for c in cols] + grid_cols = "2fr " + " ".join(["1fr"] * n_cols) + " 1fr" - return display, colors + header = '<div class="fs-th">Metric</div>' + for yr in year_labels: + header += '<div class="fs-th" style="text-align:right">' + yr + '</div>' + header += '<div class="fs-th" style="text-align:right">YoY Δ</div>' + rendered = set() + body = "" + row_idx = 0 -def _style(display: pd.DataFrame, colors: pd.DataFrame): - GREEN_BG = "background-color: rgba(46, 204, 113, 0.18); color: #7ce3a1;" - RED_BG = "background-color: rgba(231, 76, 60, 0.18); color: #ff8a8a;" + for section, candidates in groups.items(): + section_rows = [r for r in candidates if r in df.index and r not in rendered] + if not section_rows: + continue + body += ( + '<div class="fs-group-hdr" style="grid-column:1/-1">' + + _esc(section) + '</div>' + ) + for row_lbl in section_rows: + rendered.add(row_lbl) + row_data = df.loc[row_lbl] + yoy_pct = _yoy_pct(row_data.iloc[0], row_data.iloc[1]) if n_cols >= 2 else None + inv = _is_inverse(row_lbl) + bg = "var(--ink-1)" if row_idx % 2 == 1 else "var(--ink-0)" + cs = "background:" + bg + ";" + body += '<div class="fs-td-lbl" style="' + cs + '">' + _esc(row_lbl) + '</div>' + for col in cols: + body += '<div class="fs-td" style="' + cs + '">' + _fmt_cell(row_data.get(col)) + '</div>' + body += '<div class="fs-td-yoy" style="' + cs + '">' + _yoy_html(yoy_pct, inv) + '</div>' + row_idx += 1 - def apply_colors(row): - return [ - GREEN_BG if colors.loc[row.name, col] == "green" - else RED_BG if colors.loc[row.name, col] == "red" - else "" - for col in display.columns - ] + other_rows = [r for r in df.index if r not in rendered] + if other_rows: + body += ( + '<div class="fs-group-hdr" style="grid-column:1/-1">' + 'Other Reported Line Items</div>' + ) + for row_lbl in other_rows: + row_data = df.loc[row_lbl] + yoy_pct = _yoy_pct(row_data.iloc[0], row_data.iloc[1]) if n_cols >= 2 else None + inv = _is_inverse(row_lbl) + bg = "var(--ink-1)" if row_idx % 2 == 1 else "var(--ink-0)" + cs = "background:" + bg + ";" + body += '<div class="fs-td-lbl" style="' + cs + '">' + _esc(row_lbl) + '</div>' + for col in cols: + body += '<div class="fs-td" style="' + cs + '">' + _fmt_cell(row_data.get(col)) + '</div>' + body += '<div class="fs-td-yoy" style="' + cs + '">' + _yoy_html(yoy_pct, inv) + '</div>' + row_idx += 1 - return display.style.apply(apply_colors, axis=1) + return ( + '<div id="' + stmt_id + '" class="fs-stmt" style="' + display_style + '">' + '<div class="fs-stmt-card">' + '<div class="fs-table-wrap">' + '<div class="fs-table" style="display:grid;grid-template-columns:' + grid_cols + '">' + + header + body + + '</div></div></div></div>' + ) -def _augment_balance_sheet(df: pd.DataFrame, ticker: str, quarterly: bool) -> pd.DataFrame: +def _augment_balance_sheet(df, ticker: str, quarterly: bool): if quarterly or df is None or df.empty: return df - bridge = get_balance_sheet_bridge_items(ticker) prism_net_debt = bridge.get("net_debt") if prism_net_debt is None: return df - out = df.copy() if "Prism Net Debt" not in out.index: out.loc["Prism Net Debt"] = [float("nan")] * len(out.columns) @@ -273,101 +332,242 @@ def _augment_balance_sheet(df: pd.DataFrame, ticker: str, quarterly: bool) -> pd return out -def _pick_existing_rows(df: pd.DataFrame, candidates: list[str]) -> list[str]: - return [row for row in candidates if row in df.index] +def render_financials(ticker: str): + import json as _json + info = get_company_info(ticker) or {} + price = get_latest_price(ticker) -def _render_grouped_statement(df: pd.DataFrame, groups: dict[str, list[str]], empty_msg: str): - grouped_rows = [] - for section, candidates in groups.items(): - rows = _pick_existing_rows(df, candidates) - if not rows: - continue - grouped_rows.extend(rows) - 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), width="stretch") + df_income_ann = get_income_statement(ticker, quarterly=False) + df_income_qtr = get_income_statement(ticker, quarterly=True) + df_balance_ann = _augment_balance_sheet( + get_balance_sheet(ticker, quarterly=False), ticker, False + ) + df_balance_qtr = get_balance_sheet(ticker, quarterly=True) + df_cashflow_ann = get_cash_flow(ticker, quarterly=False) + df_cashflow_qtr = get_cash_flow(ticker, quarterly=True) + + # Context bar values + prev_close = info.get("previousClose") + if price and prev_close and prev_close > 0: + chg_pct = (price - prev_close) / prev_close * 100 + sign = "+" if chg_pct >= 0 else "" + arrow = "▲" if chg_pct >= 0 else "▼" + chg_str = arrow + " " + sign + f"{chg_pct:.2f}%" + chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg" + else: + chg_str, chg_cls = "—", "" - 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), width="stretch") - elif not grouped_rows: - st.info(empty_msg) + raw_x = info.get("exchange") or "" + exchange = _XMAP.get(raw_x, raw_x) or "—" + co_name = _esc(info.get("longName") or info.get("shortName") or ticker.upper()) + price_str = f"${price:,.2f}" if price else "—" + most_recent = "—" + if df_income_ann is not None and not df_income_ann.empty and len(df_income_ann.columns) > 0: + most_recent = str(df_income_ann.columns[0])[:10] -def _render_statement_block(title: str, df: pd.DataFrame, groups: dict[str, list[str]] | None, download_name: str, raw_key: str, empty_msg: str): - if df.empty: - st.info(empty_msg) - return + # Build all six statement HTML blobs + income_ann_html = _build_table_html(df_income_ann, INCOME_GROUPS, "stmt-income-annual", 4, False) + income_qtr_html = _build_table_html(df_income_qtr, INCOME_GROUPS, "stmt-income-quarterly", 8, True) + balance_ann_html = _build_table_html(df_balance_ann, BALANCE_GROUPS, "stmt-balance-annual", 4, True) + balance_qtr_html = _build_table_html(df_balance_qtr, BALANCE_GROUPS, "stmt-balance-quarterly", 8, True) + cashflow_ann_html = _build_table_html(df_cashflow_ann, CASHFLOW_GROUPS, "stmt-cashflow-annual", 4, True) + cashflow_qtr_html = _build_table_html(df_cashflow_qtr, CASHFLOW_GROUPS, "stmt-cashflow-quarterly",8, True) - view = st.radio( - f"{title} view", - ["Organized", "Raw"], - horizontal=True, - key=f"view_{raw_key}", - label_visibility="collapsed", + def _safe_len(d): + return 0 if d is None or d.empty else len(d) + + max_rows = max( + _safe_len(df_income_ann), _safe_len(df_balance_ann), _safe_len(df_cashflow_ann), + _safe_len(df_income_qtr), _safe_len(df_balance_qtr), _safe_len(df_cashflow_qtr), ) + height = 1400 + max_rows * 34 - if view == "Organized" and groups: - _render_grouped_statement(df, groups, empty_msg) - else: - display, colors = _build_statement(df) - st.dataframe(_style(display, colors), width="stretch") + _ROOT = ( + "<style>*,*::before,*::after{box-sizing:border-box}" + ":root{" + "--ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340;" + "--line-1:#232934;--line-2:#2E3645;--line-3:#3D4658;" + "--fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849;" + "--brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;--brass-ink:#17120A;" + "--oxford:#1F3D5C;--oxford-light:#2E5A87;" + "--positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517;" + "--warning:#C49545;--warning-bg:#2A1F0F;--info:#4A78B5;--info-bg:#11202E;" + "--font-display:'EB Garamond',Georgia,serif;" + "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;" + "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;" + "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;" + "--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;--fs-38:2.375rem;" + "--tr-wider:0.12em;--tr-wide:0.04em;--tr-tight:-0.02em;" + "--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;" + "--r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px;" + "--shadow-1:0 1px 3px rgba(0,0,0,0.4);" + "}" + "html,body{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);" + "font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}" + "</style>" + ) - st.download_button( - "Download CSV", - df.to_csv().encode(), - file_name=download_name, - mime="text/csv", - key=f"dl_{raw_key}", + fonts_link = ( + "<link rel='preconnect' href='https://fonts.googleapis.com'>" + "<link href='https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500" + "&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap'" + " rel='stylesheet'>" ) + _FS_CSS = """<style> +.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} +.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)} +.fs-wrap{background:var(--ink-0);min-height:100vh} +.fs-body{padding:var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-5)} +.fs-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)} +.fs-lede .left{display:flex;flex-direction:column;gap:8px} +.fs-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} +.fs-lede .sub{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);line-height:1.55;max-width:64ch;margin:0} +.fs-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)} +.fs-controls{display:flex;align-items:center;justify-content:space-between;gap:var(--sp-4);padding:var(--sp-3) var(--sp-5);background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3)} +.fs-freq,.fs-tabs{display:flex;gap:var(--sp-1)} +.fs-freq-btn,.fs-stmt-btn{font-family:var(--font-sans);font-size:var(--fs-12);font-weight:500;padding:5px 14px;border-radius:var(--r-2);border:1px solid var(--line-2);background:var(--ink-2);color:var(--fg-3);cursor:pointer;letter-spacing:var(--tr-wide)} +.fs-freq-btn:hover,.fs-stmt-btn:hover{background:var(--ink-3);color:var(--fg-2)} +.fs-freq-btn.active,.fs-stmt-btn.active{background:var(--ink-3);color:var(--brass);border-color:var(--brass-deep)} +.fs-stmt-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.fs-table-wrap{overflow-x:auto} +.fs-table{min-width:560px} +.fs-th{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;padding:8px var(--sp-4);border-bottom:1px solid var(--line-1)} +.fs-group-hdr{background:var(--ink-1);border-left:3px solid var(--brass);font-family:var(--font-display);font-style:italic;font-size:var(--fs-14);color:var(--brass);padding:var(--sp-2) var(--sp-4);border-bottom:1px solid var(--line-1)} +.fs-td-lbl{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);padding:var(--sp-2) var(--sp-4);border-bottom:1px solid var(--line-1);display:flex;align-items:center} +.fs-td{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-13);color:var(--fg-1);padding:var(--sp-2) var(--sp-4);border-bottom:1px solid var(--line-1);display:flex;align-items:center;justify-content:flex-end;text-align:right} +.fs-td-yoy{padding:var(--sp-2) var(--sp-4);border-bottom:1px solid var(--line-1);display:flex;align-items:center;justify-content:flex-end} +.fs-empty{padding:var(--sp-6);font-family:var(--font-sans);font-size:var(--fs-14);color:var(--fg-3);text-align:center} +.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)} +</style>""" -def render_financials(ticker: str): - col1, col2 = st.columns([1, 3]) - with col1: - freq = st.radio("Frequency", ["Annual", "Quarterly"], horizontal=False) - quarterly = freq == "Quarterly" + ctx_html = ( + '<div class="val-ctx">' + '<span class="sym">' + ticker.upper() + '</span>' + '<span class="name">' + co_name + '</span>' + '<span class="eyebrow-ctx" id="ctx-eyebrow" style="margin-left:12px">' + 'Financials · Income Statement</span>' + '<div class="meta">' + '<span>' + exchange + '</span>' + '<span class="px num">' + price_str + '</span>' + '<span class="' + chg_cls + ' num">' + chg_str + '</span>' + '</div></div>' + ) - tab_income, tab_balance, tab_cashflow = st.tabs([ - "Income Statement", "Balance Sheet", "Cash Flow" - ]) + lede_html = ( + '<section class="fs-lede">' + '<div class="left">' + '<span class="eyebrow-lbl">Financial Statements</span>' + '<div class="ttl">Three statements, one picture</div>' + '<p class="sub">Income statement, balance sheet, and cash flow for ' + + ticker.upper() + '. ' + 'Toggle Annual / Quarterly and switch between statements using the controls below. ' + 'YoY column reflects change vs. the prior period.</p>' + '</div>' + '<div class="right">' + '<div class="kr-source">' + '<span class="lbl">Source</span>' + '<span class="v">yfinance</span>' + '<span class="cap">Yahoo Finance API</span>' + '</div>' + '<div class="kr-source">' + '<span class="lbl">Period</span>' + '<span class="v" id="lede-period">Annual</span>' + '<span class="cap">fiscal year end</span>' + '</div>' + '<div class="kr-source">' + '<span class="lbl">As of</span>' + '<span class="v">' + most_recent + '</span>' + '<span class="cap">most recent filing</span>' + '</div>' + '</div>' + '</section>' + ) - with tab_income: - df = get_income_statement(ticker, quarterly=quarterly) - _render_statement_block( - "Income Statement", - df, - INCOME_GROUPS, - f"{ticker.upper()}_income_{'quarterly' if quarterly else 'annual'}.csv", - f"income_{ticker}_{quarterly}", - "Income statement data unavailable.", - ) + controls_html = ( + '<div class="fs-controls">' + '<div class="fs-freq">' + '<button class="fs-freq-btn active" onclick="switchFreq(\'annual\',this)">Annual</button>' + '<button class="fs-freq-btn" onclick="switchFreq(\'quarterly\',this)">Quarterly</button>' + '</div>' + '<div class="fs-tabs">' + '<button class="fs-stmt-btn active" onclick="switchStmt(\'income\',this)">Income Statement</button>' + '<button class="fs-stmt-btn" onclick="switchStmt(\'balance\',this)">Balance Sheet</button>' + '<button class="fs-stmt-btn" onclick="switchStmt(\'cashflow\',this)">Cash Flow</button>' + '</div>' + '</div>' + ) - with tab_balance: - df = get_balance_sheet(ticker, quarterly=quarterly) - df = _augment_balance_sheet(df, ticker, quarterly) - if not quarterly and not df.empty: - st.caption("Prism Net Debt is calculated as Total Debt - Cash & Equivalents using the same bridge logic as Valuation.") - _render_statement_block( - "Balance Sheet", - df, - BALANCE_GROUPS, - f"{ticker.upper()}_balance_{'quarterly' if quarterly else 'annual'}.csv", - f"balance_{ticker}_{quarterly}", - "Balance sheet data unavailable.", - ) + js = ( + '<script>' + 'var activeFreq="annual";var activeStmt="income";' + 'function showPanel(){' + 'document.querySelectorAll(".fs-stmt").forEach(function(el){el.style.display="none";});' + 'var id="stmt-"+activeStmt+"-"+activeFreq;' + 'var el=document.getElementById(id);if(el)el.style.display="block";' + 'var labels={' + '"income":"Financials · Income Statement",' + '"balance":"Financials · Balance Sheet",' + '"cashflow":"Financials · Cash Flow"' + '};' + 'var ey=document.getElementById("ctx-eyebrow");if(ey)ey.textContent=labels[activeStmt];' + 'var pe=document.getElementById("lede-period");' + 'if(pe)pe.textContent=activeFreq.charAt(0).toUpperCase()+activeFreq.slice(1);' + '}' + 'function switchFreq(freq,btn){' + 'activeFreq=freq;' + 'document.querySelectorAll(".fs-freq-btn").forEach(function(b){b.classList.remove("active");});' + 'btn.classList.add("active");showPanel();' + '}' + 'function switchStmt(stmt,btn){' + 'activeStmt=stmt;' + 'document.querySelectorAll(".fs-stmt-btn").forEach(function(b){b.classList.remove("active");});' + 'btn.classList.add("active");showPanel();' + '}' + '</script>' + ) - with tab_cashflow: - df = get_cash_flow(ticker, quarterly=quarterly) - _render_statement_block( - "Cash Flow", - df, - CASHFLOW_GROUPS, - f"{ticker.upper()}_cashflow_{'quarterly' if quarterly else 'annual'}.csv", - f"cashflow_{ticker}_{quarterly}", - "Cash flow data unavailable.", - ) + foot_html = ( + '<div class="va-foot">' + '<span>Financial data provided by Yahoo Finance via yfinance · ' + 'Values in USD unless noted · Annual figures use fiscal year end dates</span>' + '</div>' + ) + + doc = ( + "<!doctype html><html><head><meta charset='utf-8'>" + + fonts_link + + _ROOT + + _FS_CSS + + "</head><body><div class='fs-wrap'>" + + ctx_html + + '<div class="fs-body">' + + lede_html + + controls_html + + income_ann_html + + income_qtr_html + + balance_ann_html + + balance_qtr_html + + cashflow_ann_html + + cashflow_qtr_html + + foot_html + + '</div></div>' + + js + + "</body></html>" + ) + + components.html(doc, height=height, scrolling=False) |
