| """SEC filing schema — for 10-K annual reports (v2). |
| |
| Design intent |
| ------------- |
| A 10-K is a very different beast from a receipt: 50-250 pages, structured into |
| Items (1, 1A, 7, 8, ...), with a mix of long-form narrative (business |
| description, risk factors) and dense financial tables (income statement, |
| balance sheet, cash flow). |
| |
| Rather than model the whole document, we extract the three highest-signal |
| slices for downstream analysis and evaluation: |
| |
| 1. **Cover page** — identity + registration metadata (CIK, fiscal year, |
| ticker, exchange). These are canonical, auto-scrapable from EDGAR, and |
| make excellent ground-truth targets. |
| 2. **Financials** — the top-line income statement + balance-sheet |
| figures a human analyst would extract for a quick take: revenue, net |
| income, EPS, cash, debt, equity. All in the filing's reporting currency |
| (almost always USD), normalized to *absolute* dollars (i.e. the |
| extractor must undo "in millions"/"in thousands" scaling). |
| 3. **Top risk factors** — the extractor summarizes Item 1A into up to 5 |
| themed risk factors. This is the qualitative story a portfolio project |
| needs — it shows the LLM doing unstructured → structured reduction, not |
| just field-picking. |
| |
| Conventions kept in line with receipt/invoice schemas |
| ------------------------------------------------------ |
| - `StrictModel` base (extra="forbid" for OpenAI structured-outputs strict mode). |
| - `MoneyAmount = float` (never Decimal — see common.py for the tradeoff). |
| - All fields on the sub-models are Optional except a small "required core" |
| (company name, form type). Real 10-Ks omit fields; we shouldn't fail on that. |
| """ |
| from __future__ import annotations |
|
|
| from datetime import date |
|
|
| from pydantic import Field, field_validator |
|
|
| from src.schemas.base import StrictModel |
| from src.schemas.common import Address, MoneyAmount, normalize_currency, round_money |
|
|
| |
| |
| |
|
|
| class FilingCover(StrictModel): |
| """Registrant + filing metadata from the 10-K cover page. |
| |
| All of this is scrapable from EDGAR's structured header, so it's a strong |
| ground-truth target for evaluation. A well-tuned extractor should hit |
| ~100% F1 on this section — the interesting question is whether it does. |
| """ |
|
|
| company_name: str = Field(description="Full legal name of the registrant as it appears on the cover.") |
|
|
| cik: str | None = Field( |
| default=None, |
| description=( |
| "SEC Central Index Key — 10-digit numeric string with leading zeros preserved " |
| "(e.g. '0000320193' for Apple). Extract as printed if visible; else null." |
| ), |
| ) |
| ticker: str | None = Field( |
| default=None, description="Primary trading symbol, e.g. 'AAPL'. Uppercase." |
| ) |
| exchange: str | None = Field( |
| default=None, |
| description=( |
| "Registered exchange (e.g. 'NASDAQ Global Select Market', 'New York Stock Exchange'). " |
| "Verbatim from the cover — do NOT abbreviate 'NASDAQ Global Select Market' to 'NASDAQ'." |
| ), |
| ) |
|
|
| form_type: str = Field( |
| default="10-K", |
| description="Filing form (10-K, 10-K/A, 10-Q, etc.) as printed on the cover.", |
| ) |
|
|
| fiscal_year_end: date | None = Field( |
| default=None, |
| description=( |
| "Last day of the fiscal year the filing covers (ISO 8601). " |
| "Not the filing date — the *reporting period end* date." |
| ), |
| ) |
| filing_date: date | None = Field( |
| default=None, |
| description=( |
| "Date the 10-K was filed with the SEC (ISO 8601). Sometimes only " |
| "the report date is on the cover; this may need to be inferred from the header." |
| ), |
| ) |
|
|
| state_of_incorporation: str | None = Field( |
| default=None, |
| description="US state (two-letter) or country of incorporation. Uppercase.", |
| ) |
| address: Address | None = Field( |
| default=None, |
| description="Principal executive offices address if listed on the cover.", |
| ) |
|
|
| @field_validator("ticker", "state_of_incorporation", mode="before") |
| @classmethod |
| def _upper(cls, v: str | None) -> str | None: |
| return v.strip().upper() if isinstance(v, str) and v.strip() else None |
|
|
| @field_validator("cik", mode="before") |
| @classmethod |
| def _cik(cls, v): |
| """Left-pad CIK to 10 digits if the model returns 320193 not 0000320193.""" |
| if v is None: |
| return None |
| s = str(v).strip() |
| return s.zfill(10) if s.isdigit() else s |
|
|
|
|
| |
| |
| |
|
|
| class FilingFinancials(StrictModel): |
| """Top-line financial figures from the income statement, balance sheet, |
| and cash-flow statement (Item 8). |
| |
| Every monetary field is in **absolute** currency units (dollars, not |
| millions). The extraction prompt instructs the model to multiply back |
| up when the source states "(in millions)" or "(in thousands)". Getting |
| the units right is one of the harder eval targets — a factor-of-1000 |
| error is easy to make and easy to catch. |
| """ |
|
|
| currency: str = Field( |
| default="USD", |
| description="ISO 4217 code for the reporting currency (almost always USD).", |
| ) |
| fiscal_year: int | None = Field( |
| default=None, |
| ge=1900, |
| le=2100, |
| description="The 4-digit fiscal year these financials correspond to (e.g. 2025).", |
| ) |
|
|
| |
| revenue: MoneyAmount | None = Field( |
| default=None, |
| description=( |
| "Total net revenue (top line) in absolute currency units. " |
| "For Apple FY2024 this would be 391_035_000_000 (=$391.035B), NOT 391035." |
| ), |
| ) |
| cost_of_revenue: MoneyAmount | None = Field( |
| default=None, description="Cost of goods/services sold. Absolute units." |
| ) |
| gross_profit: MoneyAmount | None = Field(default=None) |
| operating_income: MoneyAmount | None = Field(default=None) |
| net_income: MoneyAmount | None = Field( |
| default=None, description="Net income attributable to the parent company." |
| ) |
|
|
| |
| eps_basic: float | None = Field( |
| default=None, description="Basic earnings per share (e.g. 6.11 for $6.11)." |
| ) |
| eps_diluted: float | None = Field(default=None, description="Diluted EPS.") |
|
|
| |
| cash_and_equivalents: MoneyAmount | None = Field( |
| default=None, |
| description="Cash and cash equivalents (excluding marketable securities).", |
| ) |
| total_debt: MoneyAmount | None = Field( |
| default=None, |
| description=( |
| "Total debt = short-term + long-term borrowings. If the filing separates " |
| "'current portion of long-term debt' from long-term debt, sum them." |
| ), |
| ) |
| total_assets: MoneyAmount | None = Field(default=None) |
| total_equity: MoneyAmount | None = Field( |
| default=None, description="Total stockholders' equity attributable to the parent." |
| ) |
|
|
| |
| operating_cash_flow: MoneyAmount | None = Field( |
| default=None, description="Net cash provided by operating activities." |
| ) |
| free_cash_flow: MoneyAmount | None = Field( |
| default=None, |
| description=( |
| "Free cash flow = operating_cash_flow - capex. If not explicitly reported, " |
| "leave null rather than compute it — many 10-Ks don't state it." |
| ), |
| ) |
|
|
| @field_validator("currency", mode="before") |
| @classmethod |
| def _currency(cls, v): |
| return normalize_currency(v) or "USD" |
|
|
| @field_validator( |
| "revenue", "cost_of_revenue", "gross_profit", "operating_income", |
| "net_income", "cash_and_equivalents", "total_debt", |
| "total_assets", "total_equity", |
| "operating_cash_flow", "free_cash_flow", |
| mode="before", |
| ) |
| @classmethod |
| def _money(cls, v): |
| return round_money(v) if v is not None else v |
|
|
|
|
| |
| |
| |
|
|
| class RiskFactor(StrictModel): |
| """A single themed risk distilled from Item 1A. |
| |
| Item 1A in a real 10-K is 20-80 pages of prose. The model reduces it to |
| at most ~5 named themes with 1-3 sentence summaries — the "TL;DR" a |
| quant would want on a first pass. `theme` is fuzzy-matchable (eval uses |
| rapidfuzz); `summary` is scored more leniently. |
| """ |
|
|
| theme: str = Field( |
| description=( |
| "Short (2-6 word) label for the risk — e.g. 'supply chain concentration', " |
| "'foreign-exchange exposure', 'regulatory / antitrust'." |
| ), |
| ) |
| summary: str = Field( |
| description=( |
| "1-3 sentence plain-English summary of the risk. Use the filer's own language " |
| "where possible; do not editorialize." |
| ), |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class Filing(StrictModel): |
| """A structured extract of a single SEC 10-K annual report. |
| |
| Composed of the three sub-schemas above. The top-level model stays flat |
| to keep the JSON output easy to consume in a downstream analytics |
| pipeline (Snowflake, DuckDB, a dashboard). |
| """ |
|
|
| cover: FilingCover = Field(description="Registrant identity + filing metadata.") |
| financials: FilingFinancials = Field( |
| description="Top-line income statement, balance sheet, and cash flow figures." |
| ) |
| top_risk_factors: list[RiskFactor] = Field( |
| default_factory=list, |
| max_length=5, |
| description=( |
| "Up to 5 themed risk factors distilled from Item 1A. " |
| "Order = the extractor's ranking of importance. Empty list allowed for filings " |
| "with unusually short or missing risk sections." |
| ), |
| ) |
|
|