aboutsummaryrefslogtreecommitdiff
path: root/components/financials.py
diff options
context:
space:
mode:
Diffstat (limited to 'components/financials.py')
-rw-r--r--components/financials.py478
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
+
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)