Spaces:
Running
Running
| """Streamlit cache layer for DB-backed lookups. | |
| Sprint 4.2 extracts the ~16 ``@st.cache_data`` helpers that used to live at the | |
| top of ``app.py`` into this dedicated module. Two goals: | |
| 1. **Shrink ``app.py``.** Pure structural move — no behaviour change. | |
| 2. **Auto-invalidate when the DB file is replaced.** Streamlit's cache keys on | |
| argument identity. Today every helper takes ``db_path: str`` as its first | |
| arg — when the DB file is replaced in-place, ``db_path`` doesn't change, so | |
| caches stay stale until manually cleared with a "Refresh data" button. | |
| Fix: each helper has two layers. A private ``_X_cached(db_version, ...)`` | |
| primitive carries an mtime-based version token as its first positional | |
| argument; Streamlit hashes it, so a new mtime → new cache key → fresh read. | |
| The public ``X(db_path, ...)`` wrapper computes the token and forwards. | |
| Call sites still pass ``db_path`` as before; the version token is | |
| transparent to callers. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sqlite3 | |
| import pandas as pd | |
| import streamlit as st | |
| from lib.data_loader import ( | |
| get_companies, | |
| get_years_available, | |
| get_form_type as _get_form_type, | |
| get_report_pdf_urls as _get_report_pdf_urls, | |
| get_company_ownership as _get_company_ownership, | |
| get_dividends as _get_dividends, | |
| get_ownership_edges as _get_ownership_edges, | |
| get_consolidated_idcodes as _get_consolidated_idcodes, | |
| get_consolidated_company_years as _get_consolidated_company_years, | |
| get_insurance_gov_source_urls as _get_insurance_gov_source_urls, | |
| get_filing_meta as _get_filing_meta, | |
| get_latest_filing_meta as _get_latest_filing_meta, | |
| get_revaluation_rows as _get_revaluation_rows, | |
| universe_stats as _universe_stats_impl, | |
| ) | |
| from lib.filing_provenance import revaluation_years as _revaluation_years | |
| from lib.income_statement import build_is_sections | |
| from lib.balance_sheet import build_bs_sections | |
| from lib.cash_flow import build_cf_sections | |
| from lib.ratios import build_ratios_table | |
| from lib.ifrs16 import compute_implied_lease_cost | |
| from lib.screener import build_metrics_table | |
| # --------------------------------------------------------------------------- | |
| # Cache-key strategy | |
| # --------------------------------------------------------------------------- | |
| def _db_version(db_path: str) -> str: | |
| """Return a cache-key token that changes whenever the DB file is replaced. | |
| Uses ``st_mtime_ns`` so even sub-second replacements bust the cache. If | |
| the file is missing we return a stable sentinel — the underlying call | |
| will surface the real error. | |
| """ | |
| try: | |
| return str(os.stat(db_path).st_mtime_ns) | |
| except OSError: | |
| return "missing" | |
| # --------------------------------------------------------------------------- | |
| # Companies / years | |
| # --------------------------------------------------------------------------- | |
| def _companies_cached(db_version: str, db_path: str): | |
| return get_companies(db_path) | |
| def companies(db_path: str): | |
| return _companies_cached(_db_version(db_path), db_path) | |
| def _years_cached(db_version: str, db_path: str, idcode: str, table: str): | |
| return get_years_available(db_path, idcode, table=table) | |
| def years(db_path: str, idcode: str, table: str = "financial_data"): | |
| return _years_cached(_db_version(db_path), db_path, idcode, table) | |
| def _report_pdf_urls_cached(db_version: str, db_path: str, idcode: str) -> dict[int, str]: | |
| return _get_report_pdf_urls(db_path, idcode) | |
| def report_pdf_urls(db_path: str, idcode: str) -> dict[int, str]: | |
| """Precomputed reportal.ge PDF links for a company, keyed by year.""" | |
| return _report_pdf_urls_cached(_db_version(db_path), db_path, idcode) | |
| def _company_ownership_cached(db_version: str, db_path: str, idcode: str) -> dict | None: | |
| return _get_company_ownership(db_path, idcode) | |
| def company_ownership(db_path: str, idcode: str) -> dict | None: | |
| """Precomputed companyinfo.ge ownership detail for a company (or None). | |
| Same dict shape as ``lib.companyinfo.fetch_company_detail``, served from the | |
| ``company_ownership`` table so the Single-Company page needs no live API call. | |
| """ | |
| return _company_ownership_cached(_db_version(db_path), db_path, idcode) | |
| def _dividends_cached(db_version: str, db_path: str, idcode: str) -> dict: | |
| return _get_dividends(db_path, idcode) | |
| def dividends(db_path: str, idcode: str) -> dict: | |
| """Dividends declared per year from ``equity_movements`` (SOCE exports). | |
| ``{"dividends": {FVYear: gel, negative = distribution}, "covered": {FVYear}}``; | |
| empty when the DB predates the feature. See ``lib.data_loader.get_dividends``. | |
| """ | |
| return _dividends_cached(_db_version(db_path), db_path, idcode) | |
| def _ownership_edges_cached(db_version: str, db_path: str) -> list[dict]: | |
| return _get_ownership_edges(db_path) | |
| def ownership_edges(db_path: str) -> list[dict]: | |
| """All company->company ownership edges (see lib.ownership). Cached per DB.""" | |
| return _ownership_edges_cached(_db_version(db_path), db_path) | |
| def _consolidated_idcodes_cached(db_version: str, db_path: str) -> set[str]: | |
| return _get_consolidated_idcodes(db_path) | |
| def consolidated_idcodes(db_path: str) -> set[str]: | |
| """IdCodes whose latest filing is consolidated (companies.LatestIsConsolidated).""" | |
| return _consolidated_idcodes_cached(_db_version(db_path), db_path) | |
| def _consolidated_company_years_cached( | |
| db_version: str, db_path: str | |
| ) -> set[tuple[str, int]]: | |
| return _get_consolidated_company_years(db_path) | |
| def _individual_basis_idcodes_cached(db_version: str, db_path: str) -> frozenset: | |
| conn = sqlite3.connect(db_path) | |
| try: | |
| have = conn.execute( | |
| "SELECT 1 FROM sqlite_master WHERE type='table' " | |
| "AND name='financial_data_individual'").fetchone() | |
| if not have: | |
| return frozenset() | |
| return frozenset( | |
| r[0] for r in conn.execute( | |
| "SELECT DISTINCT IdCode FROM financial_data_individual")) | |
| finally: | |
| conn.close() | |
| def _individual_panel_available_cached(db_version: str, db_path: str) -> bool: | |
| conn = sqlite3.connect(db_path) | |
| try: | |
| return bool(conn.execute( | |
| "SELECT 1 FROM sqlite_master WHERE type='table' " | |
| "AND name='metrics_panel_individual'").fetchone()) | |
| finally: | |
| conn.close() | |
| def individual_panel_available(db_path: str) -> bool: | |
| """True when the individual-basis metrics twin exists in this DB — the | |
| Screener's basis control only renders then, so a DB without the table | |
| (e.g. an old deployed copy) simply never offers the option.""" | |
| return _individual_panel_available_cached(_db_version(db_path), db_path) | |
| def individual_basis_idcodes(db_path: str) -> frozenset: | |
| """IdCodes whose INDIVIDUAL-basis statements are available in the sidecar. | |
| Empty when the DB has no ``financial_data_individual`` table (e.g. the | |
| deployed copy before the basis toggle ships its data) — the Single-Company | |
| basis toggle only renders for companies in this set, so the feature | |
| degrades to invisible instead of erroring. | |
| """ | |
| return _individual_basis_idcodes_cached(_db_version(db_path), db_path) | |
| def consolidated_company_years(db_path: str) -> set[tuple[str, int]]: | |
| """``(IdCode, FVYear)`` filed on a consolidated basis THAT YEAR (filing_basis). | |
| Empty when the table is missing — callers fall back to | |
| :func:`consolidated_idcodes`. See lib/consolidation.py for why the per-year | |
| fact matters to the de-dup. | |
| """ | |
| return _consolidated_company_years_cached(_db_version(db_path), db_path) | |
| def _insurance_gov_source_urls_cached(db_version: str, db_path: str, idcode: str) -> dict[int, str]: | |
| return _get_insurance_gov_source_urls(db_path, idcode) | |
| def insurance_gov_source_urls(db_path: str, idcode: str) -> dict[int, str]: | |
| """insurance.gov.ge source-XLSX links for a regulator insurer, keyed by year.""" | |
| return _insurance_gov_source_urls_cached(_db_version(db_path), db_path, idcode) | |
| # --------------------------------------------------------------------------- | |
| # Income statement / subtotals / balance sheet / ratios | |
| # --------------------------------------------------------------------------- | |
| def _is_sections_cached( | |
| db_version: str, db_path: str, idcode: str, years_tuple: tuple[int, ...], | |
| table: str, | |
| ) -> list[dict]: | |
| return build_is_sections(db_path, idcode, list(years_tuple), table=table) | |
| def is_sections(db_path: str, idcode: str, years_tuple: tuple[int, ...], | |
| table: str = "financial_data") -> list[dict]: | |
| return _is_sections_cached(_db_version(db_path), db_path, idcode, years_tuple, table) | |
| def _bs_sections_cached( | |
| db_version: str, db_path: str, idcode: str, years_tuple: tuple[int, ...], | |
| table: str, | |
| ) -> list[dict]: | |
| return build_bs_sections(db_path, idcode, list(years_tuple), table=table) | |
| def bs_sections(db_path: str, idcode: str, years_tuple: tuple[int, ...], | |
| table: str = "financial_data") -> list[dict]: | |
| return _bs_sections_cached(_db_version(db_path), db_path, idcode, years_tuple, table) | |
| def _cf_sections_cached( | |
| db_version: str, db_path: str, idcode: str, years_tuple: tuple[int, ...], include_fcf: bool, | |
| table: str, | |
| ) -> list[dict]: | |
| return build_cf_sections(db_path, idcode, list(years_tuple), include_fcf=include_fcf, | |
| table=table) | |
| def cf_sections( | |
| db_path: str, idcode: str, years_tuple: tuple[int, ...], include_fcf: bool = True, | |
| table: str = "financial_data", | |
| ) -> list[dict]: | |
| return _cf_sections_cached(_db_version(db_path), db_path, idcode, years_tuple, include_fcf, | |
| table) | |
| def _ratios_cached( | |
| db_version: str, db_path: str, idcode: str, years_tuple: tuple[int, ...], | |
| table: str, | |
| ) -> pd.DataFrame: | |
| return build_ratios_table(db_path, idcode, list(years_tuple), table=table) | |
| def ratios(db_path: str, idcode: str, years_tuple: tuple[int, ...], | |
| table: str = "financial_data") -> pd.DataFrame: | |
| return _ratios_cached(_db_version(db_path), db_path, idcode, years_tuple, table) | |
| # --------------------------------------------------------------------------- | |
| # IFRS 16 implied lease costs | |
| # --------------------------------------------------------------------------- | |
| def _implied_costs_cached( | |
| db_version: str, | |
| db_path: str, | |
| idcode: str, | |
| years_tuple: tuple[int, ...], | |
| assumed_term: float, | |
| interest_rate: float, | |
| table: str, | |
| ) -> dict: | |
| return compute_implied_lease_cost( | |
| db_path, | |
| idcode, | |
| list(years_tuple), | |
| assumed_term=assumed_term, | |
| interest_rate=interest_rate, | |
| table=table, | |
| ) | |
| def implied_costs( | |
| db_path: str, | |
| idcode: str, | |
| years_tuple: tuple[int, ...], | |
| assumed_term: float, | |
| interest_rate: float, | |
| table: str = "financial_data", | |
| ) -> dict: | |
| return _implied_costs_cached( | |
| _db_version(db_path), db_path, idcode, years_tuple, assumed_term, interest_rate, | |
| table, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Company description (custom SQL, not a lib wrapper) | |
| # --------------------------------------------------------------------------- | |
| def _company_description_cached( | |
| db_version: str, db_path: str, idcode: str | |
| ) -> dict | None: | |
| """Read the curated description + sources + sector for a single company. | |
| Returns ``{"description": str, "sources": str (json), "updated_at": str, | |
| "sector": str}`` when the company has been enriched, else ``None``. | |
| Cached so the Single Company view doesn't hit the DB on every rerun. | |
| """ | |
| conn = sqlite3.connect(db_path) | |
| try: | |
| # Defensive against stale DBs that pre-date the enrichment columns | |
| # (e.g. Space cached an older copy from the Dataset). Probe the schema | |
| # and only SELECT columns that exist; bail early if Description itself | |
| # is missing rather than letting the SQL error bubble to the UI. | |
| cols = {r[1] for r in conn.execute("PRAGMA table_info(companies)").fetchall()} | |
| if "Description" not in cols: | |
| return None | |
| select_parts = ["Description"] | |
| for opt in ("DescriptionSources", "DescriptionUpdatedAt", "Sector", "SubSector"): | |
| if opt in cols: | |
| select_parts.append(opt) | |
| else: | |
| select_parts.append("NULL") | |
| select = ", ".join(select_parts) | |
| row = conn.execute( | |
| f"SELECT {select} FROM companies WHERE IdCode = ?", | |
| (idcode,), | |
| ).fetchone() | |
| finally: | |
| conn.close() | |
| if not row or not row[0]: | |
| return None | |
| # SELECT always returns 5 columns now — missing schema columns are | |
| # padded with NULL in the SELECT list, so positions are stable. | |
| return { | |
| "description": row[0], | |
| "sources": row[1] or "[]", | |
| "updated_at": row[2] or "", | |
| "sector": row[3] or "", | |
| "sub_sector": row[4] or "", | |
| } | |
| def company_description(db_path: str, idcode: str) -> dict | None: | |
| return _company_description_cached(_db_version(db_path), db_path, idcode) | |
| def _company_sector_map_cached(db_version: str, db_path: str) -> dict[str, str]: | |
| from lib.data_loader import get_sectors | |
| return get_sectors(db_path) | |
| def company_sector_map(db_path: str) -> dict[str, str]: | |
| """``{IdCode: Sector}`` for every classified company (cached, DB-versioned).""" | |
| return _company_sector_map_cached(_db_version(db_path), db_path) | |
| # --------------------------------------------------------------------------- | |
| # Screener metrics table + helpers | |
| # --------------------------------------------------------------------------- | |
| def _metrics_table_cached(db_version: str, db_path: str, | |
| panel_table: str = "metrics_panel") -> pd.DataFrame: | |
| """Read the precomputed ``metrics_panel`` table — all columns, including growth. | |
| Sprint 2c: the growth/CAGR columns (Revenue_YoY, Revenue_2yrCAGR, …) are | |
| materialized into ``metrics_panel`` at build time by | |
| ``scripts/build_metrics_panel.py::_populate_growth_columns``. Read path | |
| drops to a single ``SELECT *`` — no runtime growth recompute. Each cell is | |
| the growth ending in that row's FVYear, so slicing the frame by year gives | |
| that year's growth (true only from 2026-07-30 — before that the builder | |
| broadcast the company's latest-year scalar onto every row). | |
| Cold call goes from ~18.5s (Sprint 2b — dominated by | |
| ``compute_growth_columns``) to <1s, completing the 144x speedup the | |
| Sprint 2b cutover promised. | |
| """ | |
| conn = sqlite3.connect(db_path) | |
| try: | |
| if panel_table not in ("metrics_panel", "metrics_panel_individual"): | |
| raise ValueError(f"unsupported panel table: {panel_table!r}") | |
| df = pd.read_sql_query(f"SELECT * FROM {panel_table}", conn) | |
| finally: | |
| conn.close() | |
| if df.empty: | |
| # Preserve the MultiIndex shape so downstream .xs() / .loc[] calls | |
| # don't crash on an empty result. | |
| if "IdCode" in df.columns and "FVYear" in df.columns: | |
| return df.set_index(["IdCode", "FVYear"]) | |
| return df | |
| return df.set_index(["IdCode", "FVYear"]).sort_index() | |
| def metrics_table(db_path: str, panel_table: str = "metrics_panel") -> pd.DataFrame: | |
| return _metrics_table_cached(_db_version(db_path), db_path, panel_table) | |
| def _panel_columns_for_idcodes_cached( | |
| db_version: str, | |
| db_path: str, | |
| idcodes_tuple: tuple[str, ...], | |
| columns_tuple: tuple[str, ...], | |
| ) -> pd.DataFrame: | |
| """Slice the requested ``metrics_panel`` columns for a set of companies. | |
| Returns a frame indexed by (IdCode, FVYear) with one column per requested | |
| metric column (only those that actually exist in the panel are returned). | |
| Used by Sector View / Compare to surface user-picked metric columns beyond | |
| the four the recompute path carries. Values are the raw (non-IFRS-adjusted) | |
| panel numbers — money in GEL, margins/returns as decimals, ratios as-is. | |
| Cached by (db_version, db_path, idcodes, columns) so flipping the picker or | |
| the company selection is an incremental cache miss, not a 51k-row re-slice | |
| on every Streamlit rerun. | |
| """ | |
| mt = _metrics_table_cached(db_version, db_path) | |
| if mt.empty: | |
| return mt | |
| keep_cols = [c for c in columns_tuple if c in mt.columns] | |
| if not keep_cols: | |
| return mt.iloc[0:0][[]] | |
| want = set(idcodes_tuple) | |
| present = mt.index.get_level_values("IdCode").isin(want) | |
| return mt.loc[present, keep_cols] | |
| def panel_columns_for_idcodes( | |
| db_path: str, idcodes: list[str], columns: list[str] | |
| ) -> pd.DataFrame: | |
| """Public wrapper: (IdCode, FVYear)-indexed frame of the requested metric | |
| columns for the given companies, read from the precomputed panel.""" | |
| return _panel_columns_for_idcodes_cached( | |
| _db_version(db_path), db_path, tuple(idcodes), tuple(columns) | |
| ) | |
| def _latest_revenue_by_idcode_cached( | |
| db_version: str, db_path: str | |
| ) -> dict[str, float]: | |
| """Latest non-zero Revenue per company, read straight from the | |
| precomputed ``company_search`` table (Sprint 2b cutover). Used to sort | |
| typeahead suggestions by size so the largest matches surface first. | |
| """ | |
| conn = sqlite3.connect(db_path) | |
| try: | |
| rows = conn.execute( | |
| "SELECT IdCode, LatestRevenue FROM company_search " | |
| "WHERE LatestRevenue IS NOT NULL AND LatestRevenue > 0" | |
| ).fetchall() | |
| finally: | |
| conn.close() | |
| return {idc: float(rev) for idc, rev in rows} | |
| def latest_revenue_by_idcode(db_path: str) -> dict[str, float]: | |
| return _latest_revenue_by_idcode_cached(_db_version(db_path), db_path) | |
| def _finder_universe_cached(db_version: str, db_path: str) -> pd.DataFrame: | |
| """One row per company for the Home finder (T0.5): the ``company_search`` | |
| row joined to that company's LATEST ``metrics_panel`` year for the size | |
| columns. ~9k rows, so the whole frame is cached and filtered in-memory | |
| by ``lib.finder``.""" | |
| conn = sqlite3.connect(db_path) | |
| try: | |
| return pd.read_sql_query( | |
| """ | |
| SELECT cs.IdCode, cs.CompanyName, cs.Sector, cs.SubSector, | |
| cs.LatestFVYear, cs.FormType, | |
| mp.Revenue, mp.NetProfit, mp.TotalAssets | |
| FROM company_search cs | |
| LEFT JOIN metrics_panel mp | |
| ON mp.IdCode = cs.IdCode AND mp.FVYear = cs.LatestFVYear | |
| """, | |
| conn, | |
| ) | |
| finally: | |
| conn.close() | |
| def finder_universe(db_path: str) -> pd.DataFrame: | |
| return _finder_universe_cached(_db_version(db_path), db_path) | |
| def _universe_stats_cached(db_version: str, db_path: str) -> dict: | |
| return _universe_stats_impl(db_path) | |
| def universe_stats(db_path: str) -> dict: | |
| return _universe_stats_cached(_db_version(db_path), db_path) | |
| def _latest_metrics_for_idcodes_cached( | |
| db_version: str, db_path: str, idcodes: tuple[str, ...] | |
| ) -> dict: | |
| """Latest-year metrics per IdCode, drawn from the cached metrics table. | |
| Returns {idcode: {Revenue, EBITDA, NetProfit, TotalAssets, TotalCash, | |
| TotalDebt, TotalEquity}} only for idcodes present in the metrics table | |
| (i.e. companies we have financials for). Used by the person portfolio. | |
| """ | |
| mt = _metrics_table_cached(db_version, db_path) | |
| if mt.empty: | |
| return {} | |
| wanted = ("Revenue", "EBITDA", "NetProfit", "TotalAssets", | |
| "TotalCash", "TotalDebt", "TotalEquity") | |
| present = set(mt.index.get_level_values("IdCode")) | |
| out: dict[str, dict] = {} | |
| for idc in idcodes: | |
| if idc not in present: | |
| continue | |
| sub = mt.xs(idc, level="IdCode").sort_index() | |
| latest = sub.iloc[-1] | |
| out[idc] = {m: float(latest.get(m) or 0.0) for m in wanted} | |
| return out | |
| def latest_metrics_for_idcodes(db_path: str, idcodes: tuple[str, ...]) -> dict: | |
| return _latest_metrics_for_idcodes_cached(_db_version(db_path), db_path, idcodes) | |
| # --------------------------------------------------------------------------- | |
| # Macro: Georgia nominal GDP (current prices), for sector GDP-penetration | |
| # --------------------------------------------------------------------------- | |
| def _gdp_by_year_cached(db_version: str, db_path: str) -> dict[int, dict]: | |
| """Read ``macro_gdp`` into ``{year: {"gdp_mln": float, "per_capita": float|None}}``. | |
| Returns ``{}`` when the table is missing (old DB that pre-dates the GDP | |
| import) so the Sector view degrades gracefully instead of crashing. | |
| GDP is stored in MILLION GEL; callers that compare against | |
| ``metrics_panel`` revenue (absolute GEL) must multiply by 1e6. | |
| """ | |
| conn = sqlite3.connect(db_path) | |
| try: | |
| names = { | |
| r[0] for r in conn.execute( | |
| "SELECT name FROM sqlite_master WHERE type='table'" | |
| ) | |
| } | |
| if "macro_gdp" not in names: | |
| return {} | |
| rows = conn.execute( | |
| "SELECT year, gdp_current_mln_gel, gdp_per_capita_gel FROM macro_gdp" | |
| ).fetchall() | |
| finally: | |
| conn.close() | |
| return { | |
| int(y): { | |
| "gdp_mln": float(gdp), | |
| "per_capita": (float(pc) if pc is not None else None), | |
| } | |
| for y, gdp, pc in rows | |
| if gdp is not None | |
| } | |
| def gdp_by_year(db_path: str) -> dict[int, dict]: | |
| return _gdp_by_year_cached(_db_version(db_path), db_path) | |
| # --------------------------------------------------------------------------- | |
| # Form type | |
| # --------------------------------------------------------------------------- | |
| def _form_type_cached(db_version: str, db_path: str, idcode: str) -> str: | |
| """Cached wrapper around lib.data_loader.get_form_type.""" | |
| return _get_form_type(db_path, idcode) | |
| def form_type(db_path: str, idcode: str) -> str: | |
| return _form_type_cached(_db_version(db_path), db_path, idcode) | |
| # --------------------------------------------------------------------------- | |
| # Filing provenance (category / reporting standard / audit requirement) | |
| # --------------------------------------------------------------------------- | |
| def _filing_meta_cached(db_version: str, db_path: str, idcode: str) -> dict[int, dict]: | |
| """Cached wrapper around lib.data_loader.get_filing_meta (per-year).""" | |
| return _get_filing_meta(db_path, idcode) | |
| def filing_meta(db_path: str, idcode: str) -> dict[int, dict]: | |
| return _filing_meta_cached(_db_version(db_path), db_path, idcode) | |
| def _latest_filing_meta_cached(db_version: str, db_path: str, idcode: str) -> dict: | |
| """Cached wrapper around lib.data_loader.get_latest_filing_meta (latest only).""" | |
| return _get_latest_filing_meta(db_path, idcode) | |
| def latest_filing_meta(db_path: str, idcode: str) -> dict: | |
| return _latest_filing_meta_cached(_db_version(db_path), db_path, idcode) | |
| def _revaluation_years_cached(db_version: str, db_path: str, idcode: str) -> set[int]: | |
| """Years with a non-zero PP&E revaluation reserve (IAS 16 revaluation model).""" | |
| return _revaluation_years(_get_revaluation_rows(db_path, idcode)) | |
| def revaluation_years(db_path: str, idcode: str) -> set[int]: | |
| return _revaluation_years_cached(_db_version(db_path), db_path, idcode) | |
| # --------------------------------------------------------------------------- | |
| # Auditors (Single-Company audit-status chip) — the RMS `auditors` table, | |
| # FY2018-2024. See lib/auditors.py for the pure row-pick/label helpers this | |
| # feeds. | |
| # --------------------------------------------------------------------------- | |
| def _audit_engagements_cached(db_version: str, db_path: str, idcode: str) -> list[dict]: | |
| conn = sqlite3.connect(db_path) | |
| try: | |
| have = conn.execute( | |
| "SELECT 1 FROM sqlite_master WHERE type='table' AND name='auditors'" | |
| ).fetchone() | |
| if not have: | |
| return [] | |
| rows = conn.execute( | |
| "SELECT FVYear, IsConsolidated, OpinionCode, AuditFirm, " | |
| "PartnerFirstName, PartnerLastName, AuditorPayment " | |
| "FROM auditors WHERE IdCode = ? " | |
| "ORDER BY FVYear DESC, IsConsolidated DESC", | |
| (idcode,), | |
| ).fetchall() | |
| finally: | |
| conn.close() | |
| return [ | |
| { | |
| "year": int(year), | |
| "is_consolidated": bool(is_cons), | |
| "opinion_code": opinion_code, | |
| "firm": firm, | |
| "partner_first": partner_first, | |
| "partner_last": partner_last, | |
| "fee": fee, | |
| } | |
| for year, is_cons, opinion_code, firm, partner_first, partner_last, fee in rows | |
| ] | |
| def audit_engagements(db_path: str, idcode: str) -> list[dict]: | |
| """This company's ``auditors`` rows — year/basis/opinion/firm/partner/fee. | |
| Empty list when the ``auditors`` table is absent from the DB (the deployed | |
| copy may lack it) — the audit-status chip degrades to invisible instead of | |
| erroring, same pattern as ``individual_basis_idcodes`` above. | |
| """ | |
| return _audit_engagements_cached(_db_version(db_path), db_path, idcode) | |
| # --------------------------------------------------------------------------- | |
| # Sector (for sector-specific statement layouts) | |
| # --------------------------------------------------------------------------- | |
| def _company_sector_cached(db_version: str, db_path: str, idcode: str) -> str: | |
| """Return companies.Sector for a single company ('' when unset/missing). | |
| Independent of the curated-Description gate in `company_description` — a | |
| company can have a Sector with no Description. Used by the Single Company | |
| view to pick the bank / insurance statement layout. | |
| """ | |
| conn = sqlite3.connect(db_path) | |
| try: | |
| cols = {r[1] for r in conn.execute("PRAGMA table_info(companies)").fetchall()} | |
| if "Sector" not in cols: | |
| return "" | |
| row = conn.execute( | |
| "SELECT Sector FROM companies WHERE IdCode = ?", (idcode,) | |
| ).fetchone() | |
| finally: | |
| conn.close() | |
| return (row[0] if row and row[0] else "") or "" | |
| def company_sector(db_path: str, idcode: str) -> str: | |
| return _company_sector_cached(_db_version(db_path), db_path, idcode) | |
| # --------------------------------------------------------------------------- | |
| # Typeahead options + screener frame reducer | |
| # --------------------------------------------------------------------------- | |
| def _options_sorted_by_revenue_cached_impl( | |
| db_version: str, db_path: str | |
| ) -> list[str]: | |
| """Pre-computed list of `"{IdCode} — {Name}"` labels sorted by latest | |
| Revenue desc. Used by the typeahead picker so the largest matching | |
| companies surface first when the user types. | |
| Sprint 2b cutover: ``company_search`` already carries the latest revenue | |
| per company, so we let SQLite do the ordering in a single query rather | |
| than joining a Python dict against the companies list. | |
| """ | |
| conn = sqlite3.connect(db_path) | |
| try: | |
| rows = conn.execute( | |
| "SELECT IdCode, CompanyName FROM company_search " | |
| "ORDER BY LatestRevenue DESC NULLS LAST, IdCode" | |
| ).fetchall() | |
| finally: | |
| conn.close() | |
| return [f"{idc} — {name}" for idc, name in rows] | |
| def options_sorted_by_revenue_cached(db_path: str) -> list[str]: | |
| return _options_sorted_by_revenue_cached_impl(_db_version(db_path), db_path) | |
| def _description_search_rows_cached_impl( | |
| db_version: str, db_path: str | |
| ) -> list[tuple[str, str, str]]: | |
| """``(IdCode, Description, description_lower)`` for enriched companies, | |
| sorted by latest Revenue desc. | |
| Company names are Georgian legal names, so ``companies.Description`` | |
| (the plain-English enrichment blurb, ~1.6k rows) is the only surface an | |
| English query like "Tegeta" can hit. The global search dialog runs a | |
| second-pass substring match over these rows after the direct label match | |
| — same approach as ``mcp/tools.py::search_companies``. The lowercase copy | |
| is precomputed so the per-keystroke scan doesn't re-lower ~1.6k blurbs. | |
| Restricted to companies present in ``company_search`` (the searchable | |
| universe) and ordered by its LatestRevenue so hits surface largest-first. | |
| Returns ``[]`` when the DB pre-dates the enrichment columns. | |
| """ | |
| conn = sqlite3.connect(db_path) | |
| try: | |
| cols = {r[1] for r in conn.execute("PRAGMA table_info(companies)").fetchall()} | |
| if "Description" not in cols: | |
| return [] | |
| rows = conn.execute( | |
| "SELECT cs.IdCode, c.Description " | |
| "FROM company_search cs " | |
| "JOIN companies c ON c.IdCode = cs.IdCode " | |
| "WHERE c.Description IS NOT NULL AND c.Description != '' " | |
| "ORDER BY cs.LatestRevenue DESC NULLS LAST, cs.IdCode" | |
| ).fetchall() | |
| finally: | |
| conn.close() | |
| return [(idc, desc, desc.lower()) for idc, desc in rows] | |
| def description_search_rows(db_path: str) -> list[tuple[str, str, str]]: | |
| return _description_search_rows_cached_impl(_db_version(db_path), db_path) | |
| def _reduce_screener_frame_cached_impl( | |
| db_version: str, db_path: str, scope: str | |
| ) -> tuple[pd.DataFrame, dict[str, int]]: | |
| """Reduce the (IdCode, FVYear) metrics panel to one row per company per the | |
| chosen year scope. Cached by (db_path, scope) so adding/removing filter | |
| rows doesn't re-do the 51k-row groupby on every Streamlit rerun. | |
| """ | |
| df = _metrics_table_cached(db_version, db_path) | |
| if df.empty: | |
| return df, {} | |
| # Growth/CAGR columns come attached to df from the panel, one value per | |
| # (IdCode, FVYear) — the growth ending in that row's year. The reductions | |
| # below therefore give: "Latest"/<year> → that year's growth (tail/loc carry | |
| # every column), "Average of last 3 years" → the mean of the last three | |
| # years' growth rates, consistent with how the scope treats every other | |
| # metric. Before 2026-07-30 the builder stored the company's latest-year | |
| # scalar on every row, so the <year> scope silently filtered on latest-year | |
| # growth. | |
| if scope == "Latest": | |
| df_sorted = df.sort_index(level=[0, 1]) | |
| latest = df_sorted.groupby(level="IdCode").tail(1) | |
| year_used = {idc: int(year) for (idc, year) in latest.index} | |
| out = latest.reset_index(level="FVYear", drop=False) | |
| return out, year_used | |
| if scope == "Average of last 3 years": | |
| df_sorted = df.sort_index(level=[0, 1]) | |
| last3 = df_sorted.groupby(level="IdCode").tail(3) | |
| avg = last3.groupby(level="IdCode").mean(numeric_only=True) | |
| latest_year = last3.reset_index().groupby("IdCode")["FVYear"].max() | |
| avg["FVYear"] = latest_year | |
| year_used = {idc: int(y) for idc, y in latest_year.items()} | |
| return avg, year_used | |
| # Specific year string | |
| try: | |
| year_int = int(scope) | |
| except (TypeError, ValueError): | |
| return df.iloc[0:0], {} | |
| sub = df.loc[df.index.get_level_values("FVYear") == year_int] | |
| if sub.empty: | |
| return ( | |
| sub.reset_index(level="FVYear", drop=False).set_index(df.index.names[0]), | |
| {}, | |
| ) | |
| out = sub.reset_index(level="FVYear", drop=False) | |
| year_used = {idc: year_int for idc in out.index} | |
| return out, year_used | |
| def reduce_screener_frame_cached( | |
| db_path: str, scope: str | |
| ) -> tuple[pd.DataFrame, dict[str, int]]: | |
| return _reduce_screener_frame_cached_impl(_db_version(db_path), db_path, scope) | |
| # --------------------------------------------------------------------------- | |
| # Insurance market dataset (premium/claims by class) — the "Insurance" view | |
| # --------------------------------------------------------------------------- | |
| from lib import insurance_market_analytics as _ima # noqa: E402 | |
| def _ins_market_years_cached(db_version: str, db_path: str) -> list[int]: | |
| return _ima.available_years(db_path) | |
| def ins_market_years(db_path: str) -> list[int]: | |
| return _ins_market_years_cached(_db_version(db_path), db_path) | |
| def _ins_market_totals_cached(db_version: str, db_path: str) -> pd.DataFrame: | |
| return _ima.market_totals(db_path) | |
| def ins_market_totals(db_path: str) -> pd.DataFrame: | |
| return _ins_market_totals_cached(_db_version(db_path), db_path) | |
| def _ins_market_uw_profit_cached(db_version: str, db_path: str) -> pd.DataFrame: | |
| return _ima.market_uw_profit(db_path) | |
| def ins_market_uw_profit(db_path: str) -> pd.DataFrame: | |
| return _ins_market_uw_profit_cached(_db_version(db_path), db_path) | |
| def _ins_class_structure_cached(db_version: str, db_path: str, year: int) -> pd.DataFrame: | |
| return _ima.class_structure(db_path, year) | |
| def ins_class_structure(db_path: str, year: int) -> pd.DataFrame: | |
| return _ins_class_structure_cached(_db_version(db_path), db_path, year) | |
| def _ins_class_dynamics_cached(db_version: str, db_path: str) -> pd.DataFrame: | |
| return _ima.class_dynamics(db_path) | |
| def ins_class_dynamics(db_path: str) -> pd.DataFrame: | |
| return _ins_class_dynamics_cached(_db_version(db_path), db_path) | |
| def _ins_company_table_cached(db_version: str, db_path: str, year: int) -> pd.DataFrame: | |
| return _ima.company_table(db_path, year) | |
| def ins_company_table(db_path: str, year: int) -> pd.DataFrame: | |
| return _ins_company_table_cached(_db_version(db_path), db_path, year) | |
| def _ins_company_class_mix_cached(db_version: str, db_path: str, idcode: str) -> pd.DataFrame: | |
| return _ima.company_class_mix(db_path, idcode) | |
| def ins_company_class_mix(db_path: str, idcode: str) -> pd.DataFrame: | |
| return _ins_company_class_mix_cached(_db_version(db_path), db_path, idcode) | |
| def _ins_company_timeseries_cached(db_version: str, db_path: str, idcode: str) -> pd.DataFrame: | |
| return _ima.company_timeseries(db_path, idcode) | |
| def ins_company_timeseries(db_path: str, idcode: str) -> pd.DataFrame: | |
| return _ins_company_timeseries_cached(_db_version(db_path), db_path, idcode) | |
| def _ins_market_hhi_cached(db_version: str, db_path: str, year: int) -> float | None: | |
| return _ima.market_hhi(db_path, year) | |
| def ins_market_hhi(db_path: str, year: int) -> float | None: | |
| return _ins_market_hhi_cached(_db_version(db_path), db_path, year) | |
| def ins_has_market_data(db_path: str) -> bool: | |
| return _ima.has_market_data(db_path) | |
| # --------------------------------------------------------------------------- | |
| # Owners / people index (views/people.py + the person dialog) | |
| # --------------------------------------------------------------------------- | |
| # `st.cache_resource`, not `st.cache_data`, for both of these. cache_data hands | |
| # back a fresh COPY of its value on every call, which for a ~16k-person nested | |
| # dict costs more per rerun than the widget interaction that triggered it; | |
| # cache_resource returns the one object. Safe because both structures are | |
| # treated as read-only downstream (lib/people.py only ever builds new dicts from | |
| # them). Invalidation still rides on the `db_version` token, so replacing the DB | |
| # file yields a new cache key exactly as it does for the cache_data helpers. | |
| def _person_index_cached(db_version: str, db_path: str) -> dict: | |
| from lib.companyinfo import summarize_affiliations | |
| from lib.data_loader import iter_ownership_details | |
| from lib.people import build_person_index | |
| names = dict(get_companies(db_path)) | |
| return build_person_index( | |
| iter_ownership_details(db_path), names, summarize_affiliations | |
| ) | |
| def person_index(db_path: str) -> dict: | |
| """``{person_id: {name, id_number, companies: {...}}}`` — the register | |
| inverted. One pass over the ~9k ownership payloads (a few seconds cold), | |
| then memoized for the life of the DB file.""" | |
| return _person_index_cached(_db_version(db_path), db_path) | |
| def _latest_portfolio_metrics_cached(db_version: str, db_path: str) -> dict: | |
| from lib.data_loader import get_latest_panel_metrics | |
| from lib.people import PORTFOLIO_METRICS | |
| return get_latest_panel_metrics(db_path, PORTFOLIO_METRICS) | |
| def latest_portfolio_metrics(db_path: str) -> dict: | |
| """``{idcode: {"year", "metrics"}}`` for every company's latest filed year — | |
| the panel side of the attributable-portfolio join.""" | |
| return _latest_portfolio_metrics_cached(_db_version(db_path), db_path) | |
| def _portfolio_metrics_filtered_cached( | |
| db_version: str, db_path: str, min_year: int | None | |
| ) -> dict: | |
| from lib.people import filter_latest_by_year | |
| return filter_latest_by_year( | |
| _latest_portfolio_metrics_cached(db_version, db_path), min_year | |
| ) | |
| def portfolio_metrics_for(db_path: str, min_year: int | None) -> dict: | |
| """The panel-side portfolio join, optionally restricted to recent filers. | |
| ``min_year=None`` keeps every vintage. See | |
| ``lib.people.filter_latest_by_year`` for why stale filings are dropped rather | |
| than zeroed.""" | |
| return _portfolio_metrics_filtered_cached(_db_version(db_path), db_path, min_year) | |
| def _people_ranked_cached( | |
| db_version: str, db_path: str, metric: str, min_year: int | None | |
| ) -> dict: | |
| from lib.people import people_top | |
| return people_top( | |
| _person_index_cached(db_version, db_path), | |
| _portfolio_metrics_filtered_cached(db_version, db_path, min_year), | |
| metric=metric, | |
| limit=0, # 0 = the FULL ranking; the view paginates it | |
| ) | |
| def people_ranked(db_path: str, metric: str, min_year: int | None = None) -> dict: | |
| """The COMPLETE owner ranking for ``metric`` (see ``lib.people.people_top``). | |
| Cached per (DB, metric, min_year) because the ranking walks every register | |
| entry and aggregates its portfolio — a few seconds over ~9k people. The Owners | |
| page paginates this, so without the cache every page turn would recompute the | |
| whole ranking. ``max_entries=16`` covers the rankable metrics x the two | |
| vintage settings. | |
| Returns the shared dict, so callers must treat ``people`` as READ-ONLY — | |
| slice it for display, never sort or mutate it in place. | |
| """ | |
| return _people_ranked_cached(_db_version(db_path), db_path, metric, min_year) | |
| # Macro-page datasets (macro_series / macro_dataset). See docs/macro-data-runbook.md. | |
| # --------------------------------------------------------------------------- | |
| def _macro_catalog_cached(db_version: str, db_path: str) -> pd.DataFrame: | |
| with sqlite3.connect(db_path) as conn: | |
| try: | |
| return pd.read_sql_query( | |
| "SELECT dataset, title, category, unit, frequency, source, " | |
| "min_period, max_period, n_rows FROM macro_dataset ORDER BY category, dataset", | |
| conn) | |
| except pd.errors.DatabaseError: | |
| return pd.DataFrame() # macro tables not present in this DB | |
| def macro_catalog(db_path: str) -> pd.DataFrame: | |
| return _macro_catalog_cached(_db_version(db_path), db_path) | |
| def _macro_series_cached(db_version: str, db_path: str, dataset: str, | |
| period_type: str | None) -> pd.DataFrame: | |
| q = ("SELECT period, period_type, breakdown, sub_breakdown, value, unit, source " | |
| "FROM macro_series WHERE dataset = ?") | |
| args: list = [dataset] | |
| if period_type: | |
| q += " AND period_type = ?" | |
| args.append(period_type) | |
| q += " AND value IS NOT NULL ORDER BY period" | |
| with sqlite3.connect(db_path) as conn: | |
| try: | |
| return pd.read_sql_query(q, conn, params=args) | |
| except pd.errors.DatabaseError: | |
| return pd.DataFrame() | |
| def macro_series(db_path: str, dataset: str, period_type: str | None = None) -> pd.DataFrame: | |
| """Tidy rows for one macro dataset (optionally filtered to a period_type).""" | |
| return _macro_series_cached(_db_version(db_path), db_path, dataset, period_type) | |
| def has_macro_data(db_path: str) -> bool: | |
| return not macro_catalog(db_path).empty | |