aboutsummaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-03-29 12:12:46 -0700
committerTyler <tyler@tylerhoang.xyz>2026-03-29 12:12:46 -0700
commitfc55820f5128f97e231de5388e59912e4a675782 (patch)
treed75aebe94f8a118ada979d23caf35848131eef48 /components
parent239eb45c0ce76ba8faf9f9bb9234d878e7ecf756 (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.py112
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)