"""Extract structured figures, computed ratios, and YoY trends from XBRL data. SEC filings carry machine-readable XBRL financials. For each filing we build: * one "label: value" chunk per statement (income / balance / cash flow), * a "Key Ratios" chunk (margins, liquidity, returns, leverage, turnover, EBITDA, free cash flow) computed deterministically in Python, and * a "Financial Trends (YoY)" chunk comparing this year to the prior year. This lets FinChat answer numeric questions -- direct figures, computed metrics, and year-over-year comparisons -- that plain text RAG cannot (the filing's tables collapse into unusable "number soup", and LLMs are unreliable at math). """ from __future__ import annotations import re from collections import defaultdict import pandas as pd from langchain_core.documents import Document _STATEMENTS = [ ("income_statement", "Income Statement"), ("balance_sheet", "Balance Sheet"), ("cash_flow_statement", "Cash Flow Statement"), ] _KEYWORDS = ("revenue, sales, income, earnings, profit, margin, assets, " "liabilities, equity, cash flow, expenses, EPS") _RATIO_KEYWORDS = ("margin, ratio, return, ROE, ROA, EBITDA, liquidity, leverage, " "turnover, profitability, quick ratio, current ratio, debt, " "free cash flow") _TREND_KEYWORDS = ("year-over-year, YoY, trend, change, improved, declined, grew, " "growth, increase, decrease, historical, compared to prior year") # Figures to show in the year-over-year trends chunk. _TREND_FIGURES = [ ("Revenue", "Revenue"), ("Gross profit", "GrossProfit"), ("Operating income", "OperatingIncomeLoss"), ("Net income", "NetIncome"), ("Total assets", "LiabilitiesAndEquity"), ("Property, plant & equipment (net)", "PlantPropertyEquipmentNet"), ("Inventory", "Inventories"), ] # --- formatting ------------------------------------------------------------- def _fmt(value, label: str = "") -> str | None: try: v = float(value) except (TypeError, ValueError): return None if pd.isna(v): return None a = abs(v) if "shares" in label.lower() and "per share" not in label.lower(): if a >= 1e9: return f"{v / 1e9:,.2f} billion shares" if a >= 1e6: return f"{v / 1e6:,.1f} million shares" return f"{v:,.0f} shares" return _money(v) if a >= 1000 else f"{v:,.2f}" def _money(v: float) -> str: a = abs(v) if a >= 1e9: return f"${v / 1e9:,.2f} billion" if a >= 1e6: return f"${v / 1e6:,.1f} million" return f"${v:,.0f}" def _fmt_metric(value: float, kind: str) -> str: if kind == "pct": return f"{value * 100:.1f}%" if kind == "x": return f"{value:.2f}" return _money(value) # --- extraction ------------------------------------------------------------- def _value_column(df: pd.DataFrame, year: int): date_cols = [c for c in df.columns if re.match(r"\d{4}-\d{2}-\d{2}", str(c))] for c in date_cols: if str(c).startswith(str(year)): return c return None def _statement_lines(stmt, year: int) -> list[str]: try: df = stmt.to_dataframe() except Exception: return [] col = _value_column(df, year) if col is None: # primary statement may label the column with the filing year only col = next((c for c in df.columns if re.match(r"\d{4}-\d{2}-\d{2}", str(c))), None) if col is None: return [] lines = [] for _, row in df.iterrows(): if row.get("abstract") or row.get("is_breakdown") or row.get("dimension"): continue label = str(row.get("label") or "").strip() if not label: continue value = _fmt(row.get(col), label) if value is None: continue lines.append(f"{label}: {value}") return lines def _collect_figures(tenk, year: int) -> dict[str, list]: """{standard_concept: [(label, value), ...]} for a given fiscal year.""" figs: dict[str, list] = defaultdict(list) for attr, _name in _STATEMENTS: stmt = getattr(tenk, attr, None) if stmt is None: continue try: df = stmt.to_dataframe() except Exception: continue col = _value_column(df, year) if col is None: continue for _, row in df.iterrows(): if row.get("is_breakdown") or row.get("dimension"): continue std = str(row.get("standard_concept") or "").strip() if not std or std == "nan": continue try: v = float(row.get(col)) except (TypeError, ValueError): continue if not pd.isna(v): figs[std].append((str(row.get("label")), v)) return figs def _first(figs, concept): return figs[concept][0][1] if figs.get(concept) else None def _operating_cash_flow(figs): for label, v in figs.get("NetCashFromOperatingActivities", []): if "operating activ" in label.lower(): return v return None # --- ratios ----------------------------------------------------------------- def _compute_ratios(figs) -> dict[str, tuple[float, str]]: """{name: (value, kind)} -- computed only where inputs are reliable.""" g = lambda c: _first(figs, c) rev = g("Revenue") ni = g("NetIncome") or g("ProfitLoss") ta = g("LiabilitiesAndEquity") # == total assets (identity) eq = g("AllEquityBalance") ca, cl = g("CurrentAssetsTotal"), g("CurrentLiabilitiesTotal") inv = g("Inventories") ltd, std = g("LongTermDebt") or 0, g("ShortTermDebt") or 0 capex, ocf = g("CapitalExpenses"), _operating_cash_flow(figs) da, ppe, rec = g("DepreciationExpense"), g("PlantPropertyEquipmentNet"), g("TradeReceivables") oi, cogs = g("OperatingIncomeLoss"), g("CostOfGoodsAndServicesSold") # Banks / financials: no classified balance sheet, unreliable revenue/COGS. is_financial = ca is None m: dict[str, tuple[float, str]] = {} if not is_financial and rev: gp = g("GrossProfit") if gp is None and cogs is not None: gp = rev - cogs for name, num in (("Gross margin", gp), ("Operating margin", oi), ("Net profit margin", ni)): if num is not None and abs(num / rev) <= 1.5: m[name] = (num / rev, "pct") if capex is not None: m["Capital expenditure as % of revenue"] = (abs(capex) / rev, "pct") if ta: m["Asset turnover"] = (rev / ta, "x") if cogs is not None and inv: m["Inventory turnover"] = (cogs / inv, "x") if rec: m["Receivables turnover"] = (rev / rec, "x") if oi is not None and da is not None: ebitda = oi + da m["EBITDA"] = (ebitda, "money") if capex is not None: m["EBITDA less capex"] = (ebitda - abs(capex), "money") if ni is not None and ta: m["Return on assets (ROA)"] = (ni / ta, "pct") if ni is not None and eq: m["Return on equity (ROE)"] = (ni / eq, "pct") if not is_financial and ca and cl and cl > 0: m["Current ratio"] = (ca / cl, "x") m["Quick ratio"] = ((ca - (inv or 0)) / cl, "x") if not is_financial and eq and (ltd or std): m["Debt-to-equity ratio"] = ((ltd + std) / eq, "x") if not is_financial and ta and ppe: m["Property, plant & equipment as % of assets"] = (ppe / ta, "pct") if not is_financial and ocf is not None and capex is not None: fcf = ocf - abs(capex) m["Free cash flow"] = (fcf, "money") if ni: m["Free cash flow conversion (FCF / net income)"] = (fcf / ni, "pct") return m def _trend_line(name, cur, prev, kind, cy, py) -> str: cs, ps = _fmt_metric(cur, kind), _fmt_metric(prev, kind) if kind == "pct": d = (cur - prev) * 100 w = "increased" if d > 0.05 else "decreased" if d < -0.05 else "roughly unchanged" return f"{name}: FY{cy} {cs} vs FY{py} {ps} ({w} {abs(d):.1f} pp)" if prev == 0: return f"{name}: FY{cy} {cs} vs FY{py} {ps}" d = (cur - prev) / abs(prev) * 100 w = "up" if d > 0.5 else "down" if d < -0.5 else "roughly flat" return f"{name}: FY{cy} {cs} vs FY{py} {ps} ({w} {abs(d):.1f}%)" def _trend_lines(figs_cur, figs_prev, cy, py) -> list[str]: lines = [] for name, concept in _TREND_FIGURES: cur, prev = _first(figs_cur, concept), _first(figs_prev, concept) if cur is not None and prev is not None: lines.append(_trend_line(name, cur, prev, "money", cy, py)) rc, rp = _compute_ratios(figs_cur), _compute_ratios(figs_prev) for name, (cur, kind) in rc.items(): if name in rp: lines.append(_trend_line(name, cur, rp[name][0], kind, cy, py)) return lines # --- documents -------------------------------------------------------------- def financial_documents(filing, ticker: str, company: str, fiscal_year: int) -> list[Document]: try: tenk = filing.obj() except Exception: return [] def _doc(name: str, content: str) -> Document: return Document( page_content=content, metadata={ "ticker": ticker, "company": company, "year": str(fiscal_year), "accession": filing.accession_no, "source": f"{company} 10-K (FY{fiscal_year}) - {name} (XBRL)", "type": "financials", }, ) docs: list[Document] = [] for attr, name in _STATEMENTS: stmt = getattr(tenk, attr, None) if stmt is None: continue lines = _statement_lines(stmt, fiscal_year) if lines: header = (f"{company} ({ticker}) FY{fiscal_year} {name} " f"(financial figures: {_KEYWORDS}; from SEC XBRL data):") docs.append(_doc(name, header + "\n" + "\n".join(lines))) figs_cur = _collect_figures(tenk, fiscal_year) ratios = _compute_ratios(figs_cur) if ratios: header = (f"{company} ({ticker}) FY{fiscal_year} Key Financial Ratios " f"(computed from SEC XBRL data - {_RATIO_KEYWORDS}):") body = "\n".join(f"{k}: {_fmt_metric(*v)}" for k, v in ratios.items()) docs.append(_doc("Key Ratios", header + "\n" + body)) figs_prev = _collect_figures(tenk, fiscal_year - 1) trends = _trend_lines(figs_cur, figs_prev, fiscal_year, fiscal_year - 1) if trends: header = (f"{company} ({ticker}) Financial Trends " f"FY{fiscal_year} vs FY{fiscal_year - 1} " f"({_TREND_KEYWORDS}; from SEC XBRL data):") docs.append(_doc("Financial Trends", header + "\n" + "\n".join(trends))) return docs