FinDashboard / lib /format.py
DMDaudio's picture
Deploy update from dev
08d98db
Raw
History Blame Contribute Delete
5.42 kB
from collections.abc import Iterable
import pandas as pd
import streamlit as st
# Visual marker prepended to a company label when its latest filed year lags the
# dataset's max year — signals "this company's most recent numbers are stale".
STALE_MARKER = "⚠" # ⚠
def _as_int_year(value) -> int | None:
"""Coerce a possibly-None / NaN / str year to int, or None if not parseable."""
if value is None:
return None
if isinstance(value, float) and pd.isna(value):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def is_stale(latest_year, dataset_max_year) -> bool:
"""True when a company's latest filed year is older than the dataset max year.
A company is "stale" if its most recent filing predates the newest year
present anywhere in the dataset — i.e. it stopped reporting before the
frontier. Unknown / unparseable latest years are treated as NOT stale
(we don't flag what we can't measure).
"""
ly = _as_int_year(latest_year)
my = _as_int_year(dataset_max_year)
if ly is None or my is None:
return False
return ly < my
def stale_count(latest_years: Iterable, dataset_max_year) -> tuple[int, int]:
"""Return ``(n_stale, n_total)`` over an iterable of latest-filed years.
``n_total`` counts only companies with a parseable latest year; companies
whose latest year is unknown are excluded from BOTH numerator and
denominator so the caption reads honestly.
"""
my = _as_int_year(dataset_max_year)
n_total = 0
n_stale = 0
for ly in latest_years:
ly_i = _as_int_year(ly)
if ly_i is None or my is None:
continue
n_total += 1
if ly_i < my:
n_stale += 1
return n_stale, n_total
def stale_caption(latest_years: Iterable, dataset_max_year) -> str | None:
"""Build a caption like '12 of 50 companies last filed before FY2024'.
Returns ``None`` when nothing is stale (or the dataset max year is unknown),
so callers can omit the caption entirely in the clean case.
"""
my = _as_int_year(dataset_max_year)
if my is None:
return None
n_stale, n_total = stale_count(latest_years, dataset_max_year)
if n_stale <= 0 or n_total <= 0:
return None
return (
f"{STALE_MARKER} {n_stale} of {n_total} companies last filed before "
f"FY{my} (marked {STALE_MARKER})."
)
def display_decimals() -> int:
"""Read the user-chosen decimal precision (0/1/2) from session state."""
return int(st.session_state.get("display_decimals", 0))
def fmt_k_gel(v) -> str:
"""Format a raw GEL value as thousands. Blank for 0/None/NaN; parens for negatives.
Decimal count is read from session state (the sidebar "Decimal precision"
selector) so the user can flip 0/1/2 globally without re-rendering wiring.
"""
if v is None:
return ""
if isinstance(v, float) and pd.isna(v):
return ""
try:
f = float(v) / 1000.0
except (TypeError, ValueError):
return str(v)
d = display_decimals()
if d == 0 and abs(f) < 0.5:
# Avoid rendering tiny values that round to zero at 0-decimal precision
# as the bare string "0" — keep them blank like true zeros for clarity.
return ""
if f == 0:
return ""
if f < 0:
return f"({abs(f):,.{d}f})"
return f"{f:,.{d}f}"
def fmt_pct(v) -> str:
"""Format a decimal proportion (0.123) as a percentage (12.3% at 1 decimal).
Decimal count follows the same session-state setting as `_fmt`.
"""
if v is None:
return ""
if isinstance(v, float) and pd.isna(v):
return ""
if v == 0:
return ""
d = display_decimals()
return f"{v * 100:,.{d}f}%"
def fmt_pct_signed(v, min_decimals: int = 1) -> str:
"""Margin-style percent: parenthesized negatives, blank for 0/None/NaN.
THE shared formatter for statement margin rows / common-size cells / CAGR
percents — previously four near-identical private copies had drifted
(2026-07-02 review). Decimals = max(global "Decimal precision" setting,
``min_decimals``): the user setting can RAISE precision, but statement
tables never drop below 1 decimal (0-decimal margins are unreadable).
"""
if v is None:
return ""
if isinstance(v, float) and pd.isna(v):
return ""
if v == 0:
return ""
d = max(display_decimals(), min_decimals)
if v < 0:
return f"({abs(v) * 100:,.{d}f}%)"
return f"{v * 100:,.{d}f}%"
def fmt_money_compact(v) -> str:
"""Compact ₾ for KPI tiles: ``₾1.2bn`` / ``₾45.1m`` / ``₾6,200k``.
Returns an em-dash for None/NaN (KPI tiles need a visible placeholder,
unlike table cells which blank). Decimals follow the global "Decimal
precision" setting, floored at 1 for bn/m so the compact form never
collapses to a bare ``₾1bn``.
"""
if v is None or (isinstance(v, float) and pd.isna(v)):
return "—"
try:
f = float(v)
except (TypeError, ValueError):
return str(v)
d = display_decimals()
a = abs(f)
if a >= 1e9:
return f"₾{f / 1e9:,.{max(d, 1)}f}bn"
if a >= 1e6:
return f"₾{f / 1e6:,.{max(d, 1)}f}m"
if a >= 1e3:
return f"₾{f / 1e3:,.{d}f}k"
return f"₾{f:,.{d}f}"