import sqlite3 from pathlib import Path import pandas as pd # Canonicalize alias spellings so old/new taxonomy variants collapse to one. LINE_ITEM_ALIASES = { "Staff Costs": "Personnel expense", "Depreciation and Amortization": "Depreciation and amortisation", "Taxes other than income tax": "Taxes other than on income", "Income Tax Expense": "Income tax", "Profit(loss)": "Profit/(loss)", # Impairment spelling variants. The reportal form carries the SAME quantity # twice per family — the IS line ("ფინანსური აქტივების გაუფასურების (ხარჯი) / # აღდგენა" → paren spelling) and a breakdown-block total ("გაუფასურების # (ხარჯი) / აღდგენა ფინანსურ აქტივებზე" → no-paren spelling); the 2021-era # exports also stamped a double-space raw-English spelling on both rows. # Without these aliases both rows sit in IS_OpEx and EBITDA counts the # impairment twice (e.g. GCAP 404549690 FY2024: -3,562K duplicated). "Impairment loss/reversal of financial assets": "Impairment (loss)/reversal of financial assets", "Impairment loss/reversal of financial assets": "Impairment (loss)/reversal of financial assets", "Impairment loss/reversal of non-financial assets": "Impairment (loss)/reversal of non- financial assets", "Impairment loss/reversal of non- financial assets": "Impairment (loss)/reversal of non- financial assets", } # Category overrides: certain line items belong to a different category than the DB records. # Applied after fetching rows, before dedup. The mapping uses the *canonical* (post-alias) name. LINE_ITEM_CATEGORY_OVERRIDES = { "Other financial expense": "IS_InterestExpense", # The FY2020-21 double-space impairment spellings were ingested under # IS_OtherExpense; post-alias they must share their IS_OpEx twins' category # so the dedup key matches (dedup keys on Category) and the line lands in # EBITDA rather than being counted again below it. "Impairment (loss)/reversal of financial assets": "IS_OpEx", "Impairment (loss)/reversal of non- financial assets": "IS_OpEx", } # Merge targets: certain line items should be SUMMED into a target name rather than # kept separate. Unlike LINE_ITEM_ALIASES (which dedups identical duplicates), this # combines distinct rows whose values should be added together. Applied AFTER dedup. LINE_ITEM_MERGE_TARGETS = { "Other financial expense": "Interest Expense", } # Section overrides: certain line items are mislabeled at the Section level in the DB. # These cash-flow reconciliation rows are stored under Section='BS_Assets'/Category='BS_Cash' # but really belong to the cash-flow statement (Section='CF'). The override moves them so # BS queries (section_prefix='BS_') don't return them, while CF analytics can still find them # under the canonical Section. LINE_ITEM_SECTION_OVERRIDES = { "Cash at the beginning of the year": "CF", "Cash at the end of the year": "CF", "Cash and Cash Equivalents at Beginning of Year": "CF", "Cash and Cash Equivalents at End of Year": "CF", "Effect of exchange rate changes on cash and cash equivalents": "CF", } def _canonical(name: str) -> str: return LINE_ITEM_ALIASES.get(name, name) def _connect(db_path: str) -> sqlite3.Connection: conn = sqlite3.connect(db_path) # Apply performance PRAGMAs. These are cheap (microseconds) and idempotent, # so applying on every connect is fine. We deliberately keep the existing # open/close lifecycle (no st.cache_resource) so callers' conn.close() # calls continue to work as before. conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA synchronous = NORMAL") conn.execute("PRAGMA mmap_size = 268435456") # 256 MB conn.execute("PRAGMA cache_size = -65536") # 64 MB conn.execute("PRAGMA temp_store = MEMORY") return conn def get_companies(db_path: str) -> list[tuple[str, str]]: """Return list of (IdCode, CompanyName) tuples, sorted by IdCode.""" conn = _connect(db_path) try: cursor = conn.execute( "SELECT IdCode, CompanyName FROM company_metadata ORDER BY IdCode" ) return [(row[0], row[1]) for row in cursor.fetchall()] finally: conn.close() def get_curated_sector_buckets(db_path: str) -> dict[str, list[str]]: """Return ``{Sector: [IdCode, IdCode, ...]}`` for the curated-sector taxonomy seeded via ``scripts/enrich_company_descriptions.py``. Each bucket's IdCode list is sorted by descending latest IS_Revenue (or IS_InterestIncome for banks), so when the Sector View loads a bucket the largest companies appear first. Buckets are returned ordered by company count (largest bucket first) for sensible UI presentation. Returns an empty dict when the DB pre-dates the Sector migration — keeps the Sector View functional against a stale cache. """ conn = _connect(db_path) try: cols = {r[1] for r in conn.execute("PRAGMA table_info(companies)").fetchall()} if "Sector" not in cols: return {} rows = conn.execute( """ WITH revs AS ( SELECT IdCode, SUM(Value) AS R FROM financial_data WHERE ItemType='TOTAL' AND Category IN ('IS_Revenue', 'IS_InterestIncome') GROUP BY IdCode ) SELECT c.Sector, c.IdCode, COALESCE(MAX(r.R), 0) AS RankR FROM companies c LEFT JOIN revs r ON r.IdCode = c.IdCode WHERE c.Sector IS NOT NULL AND c.Sector != '' GROUP BY c.Sector, c.IdCode ORDER BY c.Sector, RankR DESC """ ).fetchall() finally: conn.close() buckets: dict[str, list[str]] = {} for sector, idc, _ in rows: buckets.setdefault(sector, []).append(idc) # Re-order keys by descending company count for UI display. return dict(sorted(buckets.items(), key=lambda kv: -len(kv[1]))) def get_sectors(db_path: str) -> dict[str, str]: """Return ``{IdCode: Sector}`` for every company that has one. Empty dict when the Sector column doesn't exist or no rows are classified yet. Cheap one-shot query — used by views that want to decorate company lists with the curated sector label. """ conn = _connect(db_path) try: cols = {r[1] for r in conn.execute("PRAGMA table_info(companies)").fetchall()} if "Sector" not in cols: return {} rows = conn.execute( "SELECT IdCode, Sector FROM companies " "WHERE Sector IS NOT NULL AND Sector != ''" ).fetchall() finally: conn.close() return {idc: sec for idc, sec in rows} def get_sub_sectors(db_path: str) -> dict[str, str]: """Return ``{IdCode: SubSector}`` for every company that has one. Empty dict when the SubSector column doesn't exist or no rows are classified yet. Cheap one-shot query — used by views that want to decorate company lists with the finer-grained label. """ conn = _connect(db_path) try: cols = {r[1] for r in conn.execute("PRAGMA table_info(companies)").fetchall()} if "SubSector" not in cols: return {} rows = conn.execute( "SELECT IdCode, SubSector FROM companies " "WHERE SubSector IS NOT NULL AND SubSector != ''" ).fetchall() finally: conn.close() return {idc: sub for idc, sub in rows} def get_years_available(db_path: str, idcode: str, min_is_rows: int = 5, min_bs_rows: int = 5, table: str = "financial_data") -> list[int]: """Return sorted list of fiscal years where the company has substantive data in BOTH the income statement AND the balance sheet. A year is included only if it has at least ``min_is_rows`` non-zero IS rows AND at least ``min_bs_rows`` non-zero BS rows. This filters out stub years where only a couple of items were recorded (e.g. 2016 for many companies only has 1–2 IS rows alongside a partial BS), since such years would render as a nearly-empty Income Statement in the dashboard. """ if table not in FINANCIAL_TABLES: raise ValueError(f"unsupported financial table: {table!r}") conn = _connect(db_path) try: cursor = conn.execute( f""" SELECT FVYear FROM {table} WHERE IdCode = ? AND Value != 0 GROUP BY FVYear HAVING SUM(CASE WHEN Section = 'IS' THEN 1 ELSE 0 END) >= ? AND SUM(CASE WHEN Section LIKE 'BS_%' THEN 1 ELSE 0 END) >= ? ORDER BY FVYear """, (idcode, min_is_rows, min_bs_rows), ) return [row[0] for row in cursor.fetchall()] finally: conn.close() def get_financial_data_bulk(db_path: str, idcodes: list[str]) -> pd.DataFrame: """Every stored financial_data row for the given companies, as a DataFrame. Columns: IdCode, FVYear, Section, Category, ItemType, LineItemENG, Value. Raw stored values (no canonicalization) — the complete data dump backing the Sector View "Download all financial data" export. Returns an empty DataFrame (with the right columns) when no idcodes are given. """ cols = ["IdCode", "FVYear", "Section", "Category", "ItemType", "LineItemENG", "Value"] if not idcodes: return pd.DataFrame(columns=cols) placeholders = ",".join("?" * len(idcodes)) sql = ( f"SELECT IdCode, FVYear, Section, Category, ItemType, LineItemENG, Value " f"FROM financial_data WHERE IdCode IN ({placeholders}) " f"ORDER BY IdCode, FVYear, Section, Category, LineItemENG" ) conn = _connect(db_path) try: return pd.read_sql_query(sql, conn, params=list(idcodes)) finally: conn.close() #: Tables shaped like financial_data that the read layer may serve. The #: sidecar carries the individual-basis statements of dual-basis filers #: (scripts/build_individual_basis.py) for the Single-Company basis toggle. FINANCIAL_TABLES = ("financial_data", "financial_data_individual") def get_financial_rows( db_path: str, idcode: str, years: list[int], section_prefix: str | None = None, table: str = "financial_data", ) -> list[dict]: """ Return raw financial-data rows for a company across years. section_prefix='IS' returns IS rows; 'BS_' returns balance sheet rows. None returns all sections. ``table`` selects the source (allowlisted in :data:`FINANCIAL_TABLES` — the name is interpolated into SQL). """ if not years: return [] if table not in FINANCIAL_TABLES: raise ValueError(f"unsupported financial table: {table!r}") placeholders = ",".join("?" * len(years)) sql = f""" SELECT DISTINCT FVYear, Section, Category, ItemType, LineItemENG, Value FROM {table} WHERE IdCode = ? AND FVYear IN ({placeholders}) """ params: list = [idcode, *years] if section_prefix is not None: if section_prefix.endswith("_"): sql += " AND Section LIKE ?" params.append(section_prefix + "%") else: sql += " AND Section = ?" params.append(section_prefix) sql += " ORDER BY FVYear, Section, Category, LineItemENG" conn = _connect(db_path) try: cursor = conn.execute(sql, params) cols = [d[0] for d in cursor.description] raw = [dict(zip(cols, row)) for row in cursor.fetchall()] return canonicalize_rows(raw, section_prefix) finally: conn.close() def canonicalize_rows(raw: list[dict], section_prefix: str | None = None) -> list[dict]: """Apply the full canonicalization pipeline to already-fetched rows. This is the read-layer SSOT (Sprint 5): both ``get_financial_rows`` (the per-company IS/BS view loader) and ``lib.screener.build_metrics_table`` (the bulk metrics_panel builder) run their rows through this exact function, so alias/override/dedup/merge semantics can never diverge between the two surfaces. Rows must carry FVYear, Section, Category, LineItemENG, Value (ItemType passes through untouched). Rows are mutated in place. Steps: 1. Canonicalize names (LINE_ITEM_ALIASES), apply LINE_ITEM_CATEGORY_OVERRIDES and LINE_ITEM_SECTION_OVERRIDES. 2. Re-check ``section_prefix`` AFTER the section override and drop rows that no longer match (an override may move a row out of the requested section). 3. Dedup on (FVYear, Section, Category, LineItemENG). When an alias collision occurs (two distinct DB names canonicalize to the same item), keep the row with the LARGER ABSOLUTE VALUE rather than the first row — otherwise a zero-value legacy row can crowd out the real non-zero new-taxonomy row (e.g. Nikora 2024 has 'Taxes other than income tax' = 0 alongside 'Taxes other than on income' = -6,234K; keep-first would drop the -6,234K). 4. Sum merge-target rows (LINE_ITEM_MERGE_TARGETS) via _apply_line_item_merges. NOTE: callers iterating multiple companies must call this per company — the dedup key does not include IdCode. """ by_key: dict = {} # key -> index in out out: list = [] for r in raw: r["LineItemENG"] = _canonical(r["LineItemENG"]) if r["LineItemENG"] in LINE_ITEM_CATEGORY_OVERRIDES: r["Category"] = LINE_ITEM_CATEGORY_OVERRIDES[r["LineItemENG"]] if r["LineItemENG"] in LINE_ITEM_SECTION_OVERRIDES: r["Section"] = LINE_ITEM_SECTION_OVERRIDES[r["LineItemENG"]] if section_prefix is not None: if section_prefix.endswith("_"): if not r["Section"].startswith(section_prefix): continue elif r["Section"] != section_prefix: continue key = (r["FVYear"], r["Section"], r["Category"], r["LineItemENG"]) if key in by_key: existing = out[by_key[key]] if abs(r["Value"] or 0) > abs(existing["Value"] or 0): out[by_key[key]] = r continue by_key[key] = len(out) out.append(r) return _apply_line_item_merges(out) def _apply_line_item_merges(rows: list[dict]) -> list[dict]: """Sum rows that share a merge target into a single row. For each row whose LineItemENG is in LINE_ITEM_MERGE_TARGETS, rename it to the target name and combine with any existing row sharing the same target. Combination key is (FVYear, Section, Category, target_name). Preserves the first-seen order for stability. """ if not rows: return rows accum: dict = {} order: list = [] for r in rows: name = r["LineItemENG"] target = LINE_ITEM_MERGE_TARGETS.get(name, name) key = (r["FVYear"], r["Section"], r["Category"], target) if key not in accum: new_r = dict(r) new_r["LineItemENG"] = target accum[key] = new_r order.append(key) else: accum[key]["Value"] = (accum[key]["Value"] or 0) + (r["Value"] or 0) return [accum[k] for k in order] def universe_stats(db_path: str) -> dict[str, int]: """Return high-level counts for the Home page's "Universe" panel. Keys: - n_companies: distinct companies in company_metadata - year_min / year_max: fiscal-year coverage in financial_data - n_rows: total financial_data rows All values are plain ints so they render cleanly in markdown. """ conn = _connect(db_path) try: n_companies = conn.execute( "SELECT COUNT(*) FROM company_metadata" ).fetchone()[0] year_min, year_max = conn.execute( "SELECT MIN(FVYear), MAX(FVYear) FROM financial_data" ).fetchone() n_rows = conn.execute( "SELECT COUNT(*) FROM financial_data" ).fetchone()[0] return { "n_companies": int(n_companies), "year_min": int(year_min), "year_max": int(year_max), "n_rows": int(n_rows), } finally: conn.close() def get_form_type(db_path: str, idcode: str) -> str: """Return the company's financial-statement format type. Reads ``companies.LatestFormType``. Maps: - 'bank' → 'bank' - 'insurer' → 'insurer' - everything else (NULL, missing idcode, 'nonfin', 'cat3_simplified', any unknown future value) → 'nonfin' """ conn = _connect(db_path) try: row = conn.execute( "SELECT LatestFormType FROM companies WHERE IdCode = ?", (idcode,) ).fetchone() finally: conn.close() if row and row[0] in ("bank", "insurer"): return row[0] return "nonfin" def get_filing_meta(db_path: str, idcode: str) -> dict[int, dict]: """Per-year filing provenance from ``company_filing_meta``. Returns ``{FVYear: {"category", "form_type", "report_year", "own_year"}}``. Empty dict when the table is absent — a deployed DB predating ``scripts/build_filing_meta.py`` simply has no per-year provenance, and callers fall back to the latest-filing fields on ``companies``. Deliberately does NOT fall back here: silently serving ``LatestCategory`` for every year would reintroduce the exact error this table exists to prevent (a FY2024 category stamped on FY2019 figures). """ conn = _connect(db_path) try: rows = conn.execute( "SELECT FVYear, Category, FormType, ReportYear, IsOwnYearFiling " "FROM company_filing_meta WHERE IdCode = ?", (str(idcode),), ).fetchall() except sqlite3.OperationalError: return {} finally: conn.close() return { int(r[0]): { "category": r[1], "form_type": r[2], "report_year": r[3], "own_year": bool(r[4]), } for r in rows } def get_latest_filing_meta(db_path: str, idcode: str) -> dict: """Latest-filing category/form type from ``companies``. The fallback for a DB without ``company_filing_meta``. Correct for the company's most recent year and nothing else, which is how callers must use it. """ conn = _connect(db_path) try: row = conn.execute( "SELECT LatestCategory, LatestFormType FROM companies WHERE IdCode = ?", (str(idcode),), ).fetchone() finally: conn.close() if not row: return {"category": None, "form_type": None} return {"category": row[0], "form_type": row[1]} def get_revaluation_rows(db_path: str, idcode: str) -> list[dict]: """Non-zero balance-sheet lines whose name mentions revaluation, per year. A cheap pre-filter only — deciding which of these are *PP&E* revaluation reserves (as opposed to financial-asset or investment-property ones) is ``lib.filing_provenance.is_ppe_revaluation_line``'s job. Keyed on IdCode, which is the leading column of the ``financial_data`` primary key. """ conn = _connect(db_path) try: rows = conn.execute( "SELECT FVYear, LineItemENG, Value FROM financial_data " "WHERE IdCode = ? AND Section LIKE 'BS_%' " "AND LOWER(LineItemENG) LIKE '%revalu%' AND Value <> 0", (str(idcode),), ).fetchall() finally: conn.close() return [ {"FVYear": int(r[0]), "LineItemENG": r[1], "Value": r[2]} for r in rows ] def get_report_pdf_urls(db_path: str, idcode: str) -> dict[int, str]: """Return ``{FVYear: direct_pdf_url}`` for a company from ``report_pdf_links``. These are precomputed reportal.ge annual-report PDF links (built by ``scripts/build_report_links.py``). Only rows with a resolved URL are returned. Missing table (old DB) or missing company → empty dict, so the live-resolution fallback in ``lib/ui.py`` still applies. """ conn = _connect(db_path) try: rows = conn.execute( "SELECT FVYear, PdfUrl FROM report_pdf_links " "WHERE IdCode = ? AND PdfUrl IS NOT NULL", (str(idcode),), ).fetchall() except sqlite3.OperationalError: # report_pdf_links not present (DB predates the feature). return {} finally: conn.close() return {int(y): url for y, url in rows} def get_dividends(db_path: str, idcode: str) -> dict: """Dividends declared per year from the ``equity_movements`` SOCE data. Returns ``{"dividends": {FVYear: gel}, "covered": {FVYear, ...}}`` where dividend values keep the filed sign (negative = distribution to owners). ``covered`` lists the years with ANY clean equity-movement rows, so a covered year absent from ``dividends`` means "declared zero" — distinct from "no SOCE data". Suspect filings (internally inconsistent numbers) are excluded from both. Missing table/view (DB predates the feature) → empty. """ conn = _connect(db_path) try: div_rows = conn.execute( "SELECT FVYear, DividendsDeclared FROM v_dividends " "WHERE IdCode = ? AND Suspect = 0", (str(idcode),), ).fetchall() cov_rows = conn.execute( "SELECT DISTINCT FVYear FROM equity_movements " "WHERE IdCode = ? AND Suspect = 0", (str(idcode),), ).fetchall() except sqlite3.OperationalError: # equity_movements / v_dividends not present (DB predates the feature). return {"dividends": {}, "covered": set()} finally: conn.close() return { "dividends": {int(y): float(v) for y, v in div_rows if v is not None}, "covered": {int(y) for (y,) in cov_rows}, } def get_company_ownership(db_path: str, idcode: str) -> dict | None: """Return the precomputed companyinfo.ge detail dict for a company, or None. Reads ``company_ownership`` (built by ``scripts/build_company_ownership.py``), gzip-decompressing the stored DetailGz blob into the detail dict so callers can pass it straight to ``lib.companyinfo.summarize_affiliations`` — the same shape ``fetch_company_detail`` returns from the live API. None means: no precomputed row, a 'notfound'/'error' status, or the table is absent (old DB). Callers use that to decide whether to fall back to a live fetch (Ownership page only) — the whole point being that the statements never make a live companyinfo call. """ conn = _connect(db_path) try: row = conn.execute( "SELECT DetailGz FROM company_ownership " "WHERE IdCode = ? AND Status = 'ok' AND DetailGz IS NOT NULL", (str(idcode),), ).fetchone() except sqlite3.OperationalError: # company_ownership not present (DB predates the feature). return None finally: conn.close() if not row or not row[0]: return None try: import gzip import json as _json # DetailGz is gzip-compressed UTF-8 JSON (~12x smaller than raw — the # full companyinfo detail incl. historical roster is ~15KB/co, so the # table is 176MB raw vs 18MB compressed). return _json.loads(gzip.decompress(row[0]).decode("utf-8")) except (ValueError, TypeError, OSError): return None def get_bia_directory(db_path: str, idcode: str) -> dict | None: """Return the verified bia.ge directory detail for a company, or None. Reads ``bia_directory`` (built by ``scripts/build_bia_directory.py``), gzip-decompressing ``DetailGz`` into the dict ``lib.bia.parse_company_page`` produced: name, products, activity categories, activity fields, NACE codes, legal form, address. **Only ``Status='ok'`` rows are returned, and an ``ok`` row is one whose page ``საიდენტიფიკაციო კოდი`` was verified equal to this IdCode.** The ``code_mismatch`` / ``no_code`` / ``notfound`` rows are deliberately readable only by going to the table directly — a caller asking "what does bia say about this company?" must never be handed a same-named different company. None means: no row, an unverified row, or the table is absent (older DB). """ conn = _connect(db_path) try: row = conn.execute( "SELECT DetailGz FROM bia_directory " "WHERE IdCode = ? AND Status = 'ok' AND DetailGz IS NOT NULL", (str(idcode),), ).fetchone() except sqlite3.OperationalError: # bia_directory not present (DB predates the feature). return None finally: conn.close() if not row or not row[0]: return None try: import gzip import json as _json return _json.loads(gzip.decompress(row[0]).decode("utf-8")) except (ValueError, TypeError, OSError): return None def iter_bia_directory(db_path: str): """Yield ``(IdCode, detail_dict)`` for every code-VERIFIED bia.ge row. Streamed, like ``iter_ownership_details``, so a whole-corpus sector cross-check never holds every payload plus its gzip blob in memory. Rows that fail to decompress are skipped; a missing table yields nothing. """ import gzip import json as _json conn = _connect(db_path) try: try: cursor = conn.execute( "SELECT IdCode, DetailGz FROM bia_directory " "WHERE Status = 'ok' AND DetailGz IS NOT NULL" ) except sqlite3.OperationalError: return for idcode, blob in cursor: try: yield str(idcode), _json.loads(gzip.decompress(blob).decode("utf-8")) except (ValueError, TypeError, OSError): continue finally: conn.close() def get_consolidated_company_years(db_path: str) -> set[tuple[str, int]]: """``(IdCode, FVYear)`` pairs that filed on a CONSOLIDATED basis THAT YEAR. Read from ``filing_basis`` (built by ``scripts/build_filing_basis.py`` from the raw exports' ``CategoryMain``; "ჯგუფი" => consolidated). Strictly better information than ``get_consolidated_idcodes``, which reports only the company's *latest* basis. NOT currently used by the de-dup gate — see ``lib/consolidation.py`` for the measurement showing the per-year swap is revenue-neutral while adding weakly-evidenced drops, and that the real gap is missing ownership *vintage*. Kept as the input for the planned individual/consolidated switcher and for re-measuring the gate once ownership start dates exist. Returns an EMPTY set when the table is absent, which callers must treat as "no per-year data" — not as "nothing is consolidated". """ conn = _connect(db_path) try: rows = conn.execute( "SELECT IdCode, FVYear FROM filing_basis WHERE HasConsolidated = 1" ).fetchall() except sqlite3.OperationalError: return set() finally: conn.close() return {(str(r[0]), int(r[1])) for r in rows} def get_consolidated_idcodes(db_path: str) -> set[str]: """IdCodes whose LATEST filing is on a CONSOLIDATED basis (``companies. LatestIsConsolidated = 1``). This is the reportal-sourced statement-basis flag: a consolidated filer's Revenue/EBITDA/Assets already contain its subsidiaries. NOTE: it is a *latest-filing* fact — prefer :func:`get_consolidated_company_years` for the de-dup gate and use this only as a fallback. Empty set if the column is absent. """ conn = _connect(db_path) try: rows = conn.execute( "SELECT IdCode FROM companies WHERE LatestIsConsolidated = 1" ).fetchall() except sqlite3.OperationalError: return set() finally: conn.close() return {str(r[0]) for r in rows} def get_ownership_edges(db_path: str) -> list[dict]: """All corporate ownership edges from ``ownership_edges`` as edge dicts. Each edge ``{"child","parent","share","is_internal","parent_name","since"}`` means ``child`` is owned BY ``parent`` (``share`` %); ``is_internal`` is True iff the parent itself files with us. Built by ``scripts/build_ownership_edges.py`` from ``company_ownership``. Empty list when the table is absent (older DB) so callers degrade to "no ownership data" rather than erroring. See ``lib/ownership.py``. """ conn = _connect(db_path) try: rows = conn.execute( "SELECT ChildIdCode, ParentIdCode, Share, IsInternal, ParentName, " "SinceDate FROM ownership_edges" ).fetchall() except sqlite3.OperationalError: return [] finally: conn.close() return [ {"child": str(r[0]), "parent": str(r[1]), "share": r[2] or 0.0, "is_internal": bool(r[3]), "parent_name": r[4], "since": (r[5] if len(r) > 5 else None)} for r in rows ] def iter_ownership_details(db_path: str): """Yield ``(IdCode, detail_dict)`` for every successfully-scraped company. The whole ``company_ownership`` table, streamed rather than fetched, so building the owners index never holds 9k decompressed registry payloads and 9k gzip blobs in memory at once. Rows that fail to decompress are skipped — one corrupt blob must not sink the index. Missing table (older DB) yields nothing, so the Owners view shows its empty state instead of erroring. """ import gzip import json as _json conn = _connect(db_path) try: try: cursor = conn.execute( "SELECT IdCode, DetailGz FROM company_ownership " "WHERE Status = 'ok' AND DetailGz IS NOT NULL" ) except sqlite3.OperationalError: return for idcode, blob in cursor: try: yield str(idcode), _json.loads(gzip.decompress(blob).decode("utf-8")) except (ValueError, TypeError, OSError): continue finally: conn.close() def get_latest_panel_metrics(db_path: str, metrics: tuple[str, ...]) -> dict: """Every company's LATEST filed year from ``metrics_panel``, keyed by IdCode. Returns ``{idcode: {"year": int, "metrics": {metric: value}}}`` in one scan. The owners leaderboard weights ~11k portfolios against this; doing it as a per-company lookup inside that loop is the shape that takes minutes. ``ORDER BY IdCode, FVYear`` ascending means the last write per IdCode wins, which is the latest year — no GROUP BY, no window function. """ cols = ", ".join(metrics) conn = _connect(db_path) try: rows = conn.execute( f"SELECT IdCode, FVYear, {cols} FROM metrics_panel ORDER BY IdCode, FVYear" ).fetchall() except sqlite3.OperationalError: return {} finally: conn.close() return { str(r[0]): {"year": r[1], "metrics": dict(zip(metrics, r[2:]))} for r in rows } def get_insurance_gov_source_urls(db_path: str, idcode: str) -> dict[int, str]: """Return ``{FVYear: insurance.gov.ge source-XLSX URL}`` for a regulator insurer. Built from the ``SourceFileId`` recorded per row in ``insurance_statements`` (the actual data source behind a regulator-covered insurer's statements). Missing table / uncovered company → empty dict. """ from lib.insurance_gov import insurance_gov_source_url conn = _connect(db_path) try: rows = conn.execute( "SELECT DISTINCT FVYear, SourceFileId FROM insurance_statements " "WHERE IdCode = ? AND SourceFileId IS NOT NULL", (str(idcode),), ).fetchall() except sqlite3.OperationalError: return {} finally: conn.close() return {int(y): insurance_gov_source_url(fid) for y, fid in rows}