From a3771ecb46e81bb9d996da1e850ec9e4b9ed3c76 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 15 May 2026 19:10:37 -0700 Subject: Redesigned Financials tab with components.html() dark-terminal aesthetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/financials.py | 496 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 348 insertions(+), 148 deletions(-) (limited to 'components') 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 '' 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) - - 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) + pct_str = str(round(abs(pct), 1)) + "%" + if abs(pct) < 1.0: + return ( + '' + + arrow + " " + pct_str + + '' + ) + 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 ( + '' + + arrow + " " + pct_str + + '' + ) - 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" - colors[yoy_label] = pd.Series( - [cell_color(idx, pct) for idx, pct in zip(df.index, raw_yoy)], - index=df.index, - ) +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 ( + '
' + '
Data unavailable for this statement.
' + '
' + ) - return display, colors + cols = list(df.columns[:n_periods]) + n_cols = len(cols) + if n_cols == 0: + return ( + '
' + '
No periods available.
' + '
' + ) + year_labels = [str(c)[:4] for c in cols] + grid_cols = "2fr " + " ".join(["1fr"] * n_cols) + " 1fr" -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;" + header = '
Metric
' + for yr in year_labels: + header += '
' + yr + '
' + header += '
YoY Δ
' - 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 - ] + rendered = set() + body = "" + row_idx = 0 - return display.style.apply(apply_colors, axis=1) + 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 += ( + '
' + + _esc(section) + '
' + ) + 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 += '
' + _esc(row_lbl) + '
' + for col in cols: + body += '
' + _fmt_cell(row_data.get(col)) + '
' + body += '
' + _yoy_html(yoy_pct, inv) + '
' + row_idx += 1 + + other_rows = [r for r in df.index if r not in rendered] + if other_rows: + body += ( + '
' + 'Other Reported Line Items
' + ) + 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 += '
' + _esc(row_lbl) + '
' + for col in cols: + body += '
' + _fmt_cell(row_data.get(col)) + '
' + body += '
' + _yoy_html(yoy_pct, inv) + '
' + row_idx += 1 + + return ( + '
' + '
' + '
' + '
' + + header + body + + '
' + ) -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") - - 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) - - -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 - - view = st.radio( - f"{title} view", - ["Organized", "Raw"], - horizontal=True, - key=f"view_{raw_key}", - label_visibility="collapsed", + 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 ) - - if view == "Organized" and groups: - _render_grouped_statement(df, groups, empty_msg) + 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: - display, colors = _build_statement(df) - st.dataframe(_style(display, colors), width="stretch") - - st.download_button( - "Download CSV", - df.to_csv().encode(), - file_name=download_name, - mime="text/csv", - key=f"dl_{raw_key}", + chg_str, chg_cls = "—", "" + + 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] + + # 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) + + 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 + + _ROOT = ( + "" ) + fonts_link = ( + "" + "" + ) -def render_financials(ticker: str): - col1, col2 = st.columns([1, 3]) - with col1: - freq = st.radio("Frequency", ["Annual", "Quarterly"], horizontal=False) - quarterly = freq == "Quarterly" - - tab_income, tab_balance, tab_cashflow = st.tabs([ - "Income Statement", "Balance Sheet", "Cash Flow" - ]) - - 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.", - ) + _FS_CSS = """""" + + ctx_html = ( + '
' + '' + ticker.upper() + '' + '' + co_name + '' + '' + 'Financials · Income Statement' + '
' + '' + exchange + '' + '' + price_str + '' + '' + chg_str + '' + '
' + ) - 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.", - ) + lede_html = ( + '
' + '
' + 'Financial Statements' + '
Three statements, one picture
' + '

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.

' + '
' + '
' + '
' + 'Source' + 'yfinance' + 'Yahoo Finance API' + '
' + '
' + 'Period' + 'Annual' + 'fiscal year end' + '
' + '
' + 'As of' + '' + most_recent + '' + 'most recent filing' + '
' + '
' + '
' + ) - 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.", - ) + controls_html = ( + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '' + '
' + '
' + ) + + js = ( + '' + ) + + foot_html = ( + '
' + 'Financial data provided by Yahoo Finance via yfinance · ' + 'Values in USD unless noted · Annual figures use fiscal year end dates' + '
' + ) + + doc = ( + "" + + fonts_link + + _ROOT + + _FS_CSS + + "
" + + ctx_html + + '
' + + lede_html + + controls_html + + income_ann_html + + income_qtr_html + + balance_ann_html + + balance_qtr_html + + cashflow_ann_html + + cashflow_qtr_html + + foot_html + + '
' + + js + + "" + ) + + components.html(doc, height=height, scrolling=False) -- cgit v1.3-2-g0d8e