diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-03-29 12:12:46 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-03-29 12:12:46 -0700 |
| commit | fc55820f5128f97e231de5388e59912e4a675782 (patch) | |
| tree | d75aebe94f8a118ada979d23caf35848131eef48 /components | |
| parent | 239eb45c0ce76ba8faf9f9bb9234d878e7ecf756 (diff) | |
Color code financial statement YoY columns
Green for improvement, red for decline. Cost/expense rows use
inverse logic (decline = green). Value cells retain neutral styling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'components')
| -rw-r--r-- | components/financials.py | 112 |
1 files changed, 82 insertions, 30 deletions
diff --git a/components/financials.py b/components/financials.py index 547aedb..828f256 100644 --- a/components/financials.py +++ b/components/financials.py @@ -1,33 +1,21 @@ """Financial statements — Income Statement, Balance Sheet, Cash Flow.""" +import numpy as np import pandas as pd import streamlit as st from services.data_service import get_income_statement, get_balance_sheet, get_cash_flow from utils.formatters import fmt_large +# Rows where a decline is actually good (e.g. costs, expenses) +_INVERSE_ROWS = { + "cost of revenue", "cost of goods sold", "operating expenses", + "selling general administrative", "research development", + "interest expense", "income tax expense", "total expenses", + "reconciled cost of revenue", +} -def _format_statement(df: pd.DataFrame) -> pd.DataFrame: - """Format a yfinance financial statement for display.""" - if df.empty: - return df - # Columns are datetime; convert to year strings - df = df.copy() - df.columns = [str(c)[:10] for c in df.columns] - - # Add YoY % change columns if >= 2 periods - cols = list(df.columns) - result = pd.DataFrame(index=df.index) - - for i, col in enumerate(cols): - result[col] = df[col].apply(_fmt_cell) - if i + 1 < len(cols): - prev_col = cols[i + 1] - yoy = df.apply( - lambda row: _yoy_pct(row[col], row[prev_col]), axis=1 - ) - result[f"YoY {col[:4]}"] = yoy - - return result +def _is_inverse(label: str) -> bool: + return label.strip().lower() in _INVERSE_ROWS def _fmt_cell(value) -> str: @@ -38,16 +26,77 @@ def _fmt_cell(value) -> str: return fmt_large(v) -def _yoy_pct(current, previous) -> str: +def _yoy_raw(current, previous): + """Return raw float YoY % change, or None.""" try: c, p = float(current), float(previous) if p == 0: - return "—" - pct = (c - p) / abs(p) * 100 - arrow = "▲" if pct >= 0 else "▼" - return f"{arrow} {abs(pct):.1f}%" + return None + return (c - p) / abs(p) * 100 except (TypeError, ValueError): + return None + + +def _yoy_str(pct) -> str: + if pct is None: return "—" + arrow = "▲" if pct >= 0 else "▼" + return f"{arrow} {abs(pct):.1f}%" + + +def _build_statement(df: pd.DataFrame): + """ + Returns (display_df, color_df). + display_df: formatted string DataFrame for rendering. + color_df: same shape, cells are 'green', 'red', or '' for styling. + """ + 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) + + 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, + ) + + return display, colors + + +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;" + + 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 + ] + + return display.style.apply(apply_colors, axis=1) def render_financials(ticker: str): @@ -65,18 +114,21 @@ def render_financials(ticker: str): if df.empty: st.info("Income statement data unavailable.") else: - st.dataframe(_format_statement(df), use_container_width=True) + display, colors = _build_statement(df) + st.dataframe(_style(display, colors), use_container_width=True) with tab_balance: df = get_balance_sheet(ticker, quarterly=quarterly) if df.empty: st.info("Balance sheet data unavailable.") else: - st.dataframe(_format_statement(df), use_container_width=True) + display, colors = _build_statement(df) + st.dataframe(_style(display, colors), use_container_width=True) with tab_cashflow: df = get_cash_flow(ticker, quarterly=quarterly) if df.empty: st.info("Cash flow data unavailable.") else: - st.dataframe(_format_statement(df), use_container_width=True) + display, colors = _build_statement(df) + st.dataframe(_style(display, colors), use_container_width=True) |
