File size: 10,521 Bytes
c6cfb1f | 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 | """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
# ---------------------------------------------------------------------------
# 1) COVER PAGE
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
# 2) FINANCIALS
# ---------------------------------------------------------------------------
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).",
)
# --- Income statement -----------------------------------------------
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."
)
# --- Per-share ------------------------------------------------------
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.")
# --- Balance sheet ---------------------------------------------------
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."
)
# --- Cash flow -------------------------------------------------------
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
# ---------------------------------------------------------------------------
# 3) RISK FACTORS (Item 1A)
# ---------------------------------------------------------------------------
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."
),
)
# ---------------------------------------------------------------------------
# TOP-LEVEL FILING
# ---------------------------------------------------------------------------
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."
),
)
|