File size: 10,982 Bytes
48c9780
 
 
 
 
 
 
 
 
 
 
8db8a8c
 
 
 
087a312
8db8a8c
 
 
 
 
 
 
 
 
 
 
48c9780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8db8a8c
48c9780
8db8a8c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48c9780
8db8a8c
 
087a312
 
 
 
 
 
 
 
 
48c9780
 
 
 
 
 
 
 
 
 
 
8db8a8c
 
48c9780
8db8a8c
48c9780
8db8a8c
 
48c9780
8db8a8c
 
 
 
48c9780
 
 
 
 
8db8a8c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48c9780
 
087a312
 
 
 
 
 
 
 
 
48c9780
087a312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48c9780
 
087a312
 
 
 
48c9780
087a312
48c9780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
087a312
 
48c9780
087a312
 
 
 
48c9780
 
087a312
48c9780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
087a312
48c9780
 
 
 
 
 
087a312
48c9780
 
 
 
087a312
 
 
48c9780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8db8a8c
 
 
 
 
 
 
087a312
 
 
 
48c9780
087a312
 
 
 
 
 
8db8a8c
 
 
 
 
 
48c9780
 
 
 
087a312
48c9780
 
087a312
48c9780
 
 
087a312
 
48c9780
 
 
 
 
 
 
 
8db8a8c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
"""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