Skip to content

Commit 5838f88

Browse files
committed
feat: Add XBRL dataclass models and methods for fetching company facts
- Introduced `Fact` and `Facts` models in `edgar/models.py` to represent XBRL data points and structured company facts. - Implemented `get_facts()` method in `Company` class to return a `Facts` model. - Updated `Xbrl` class with `get_facts()` method for fetching structured facts by CIK. - Enhanced `company_concepts()` and `frames()` methods to accept an optional `taxonomy` parameter. - Added sample usage script `samples/use_xbrl_facts.py` demonstrating the new functionality. - Created unit tests for `Fact` and `Facts` models in `tests/test_xbrl_facts.py`. - Updated `CHANGELOG.md` to document the new features and changes.
1 parent c0b9c65 commit 5838f88

8 files changed

Lines changed: 996 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
12+
- **edgar/models.py**: `Fact` and `Facts` XBRL dataclass models.
13+
- `Facts` wraps the deeply nested `company_facts` JSON (4 levels) with `get(taxonomy, concept, unit=None)` returning a flat `list[Fact]` sorted by end date.
14+
- `Facts.taxonomies` lists available namespaces (e.g. `['dei', 'us-gaap', 'ifrs-full']`).
15+
- `Facts.concepts(taxonomy)` lists concept names within a taxonomy.
16+
- `Facts.label()`, `Facts.description()`, `Facts.units()` for concept metadata.
17+
- `Fact` wraps a single data point with `value`, `end`, `start`, `fiscal_year`, `fiscal_period`, `form`, `filed`, `frame` properties.
18+
- **xbrl.py**: `get_facts(cik)` method returning a structured `Facts` model.
19+
- **company.py**: `get_facts()` method returning a structured `Facts` model.
20+
- **tests/test_xbrl_facts.py**: 39 unit tests for `Fact`, `Facts`, `Company.get_facts()`, `Xbrl.get_facts()`, and taxonomy parameter support.
21+
22+
### Changed
23+
24+
- **xbrl.py**: `company_concepts()` and `frames()` now accept an optional `taxonomy` parameter (default `"us-gaap"`). Previously hardcoded to `us-gaap`, now supports `"ifrs-full"`, `"dei"`, or any other taxonomy.
1125
- **edgar/tickers.py**: New `Tickers` service for ticker/CIK/company name resolution via `sec.gov/files/company_tickers.json`.
1226
- `resolve_ticker("AAPL")` → zero-padded CIK string (`"0000320193"`).
1327
- `resolve_cik(320193)` → list of company entries (ticker, title, CIK).
@@ -38,19 +52,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3852
- **README.md**: Complete rewrite with hero example, full service table (15 services), usage examples for ticker resolution, fluent Company API, XBRL, filing search, downloads, response models, and badge row.
3953
- **samples/use_company.py**: Sample file demonstrating the fluent Company interface (creation by ticker/CIK, filings, submissions, XBRL, download).
4054
- **samples/use_models.py**: Sample file demonstrating structured dataclass response models (`Filing`, `CompanyInfo`, `Submission`).
55+
- **samples/use_xbrl_facts.py**: Sample file demonstrating `Facts` and `Fact` XBRL dataclass models (taxonomy browsing, concept retrieval, unit filtering, metadata, cross-taxonomy access).
4156
- **tests/test_rate_limiter.py**: 9 unit tests for the sliding-window rate limiter (under-limit, at-limit sleep, timestamp expiry, integration checks for all three request paths).
4257

4358
### Changed
59+
4460
- **session.py**: Replaced counter-based rate limiter (`sleep 5s every 10 requests`) with a sliding-window algorithm using `collections.deque` of `time.monotonic()` timestamps. Sleeps only the minimum time needed when the 1-second window is full. `MAX_REQUESTS_PER_SECOND = 10` enforced per SEC policy.
4561
- **session.py**: Rate limiting now applies to all three outgoing request paths (`make_request()`, `fetch_page()`, `download()`). Previously `fetch_page()` and `download()` bypassed rate limiting entirely.
4662

4763
### Changed
64+
4865
- Migrated from `setup.py` to `pyproject.toml` for modern packaging.
4966
- Relaxed dependency version pins to use minimum ranges instead of exact versions.
5067
- Updated minimum Python version to 3.9.
5168
- Excluded `samples/` and `tests/` from distributed package.
5269

5370
### Fixed
71+
5472
- **enums.py**: Renamed `StateCodes` members from mixed-case (`Alabama`, `New_York`) to UPPER_CASE (`ALABAMA`, `NEW_YORK`) to follow Python enum naming conventions.
5573
- **utils.py**: Exception chaining — `except ValueError as exc` / `raise ... from exc` in `parse_dates`.
5674
- **session.py**: Replaced infinite retry loop with bounded retry (max 5) and exponential backoff.
@@ -90,11 +108,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
90108
- **All examples**: Updated `EdgarClient()``EdgarClient(user_agent=...)` across 13 sample files, README.md, test file, and 55 docstring examples in 14 `edgar/` modules to reflect the required `user_agent` parameter.
91109

92110
### Removed
111+
93112
- **`edgar/parser/xbrl.py`**: Deleted `XbrlFiling` stub class — never imported or referenced.
94113
- **`edgar/parser/`**: Removed empty directory that conflicted with `parser.py` module.
95114
- **`edgar/enums.py`**: Replaced monolithic 1581-line file with `edgar/enums/` package — one module per enum class (`state_codes.py`, `country_codes.py`, `filing_type_codes.py`, `sic_codes.py`, `other_filing_types.py`) plus `__init__.py` re-exporting all names. All existing `from edgar.enums import X` imports continue to work.
96115

97116
### Added
117+
98118
- `py.typed` marker for PEP 561 type checker support.
99119
- `CHANGELOG.md` to track version history.
100120
- `.gitignore` file.
@@ -111,5 +131,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
111131
## [0.1.6] - 2021-01-01
112132

113133
### Added
134+
114135
- Initial public release.
115136
- EDGAR client with services: Archives, Companies, CurrentEvents, Datasets, Filings, Issuers, MutualFunds, OwnershipFilings, Series, Submissions, VariableInsuranceProducts, XBRL.

edgar/company.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,28 @@ def get_info(self) -> object:
251251
if raw is None:
252252
return None
253253
return CompanyInfo(raw=raw)
254+
255+
def get_facts(self) -> object:
256+
"""Returns XBRL company facts as a structured ``Facts`` model.
257+
258+
Wraps the raw ``xbrl_facts()`` response in a ``Facts``
259+
dataclass for convenient access by taxonomy, concept, and unit.
260+
261+
### Returns
262+
----
263+
Facts | None:
264+
A ``Facts`` object, or ``None`` if no data was returned.
265+
266+
### Usage
267+
----
268+
>>> company = edgar_client.company("AAPL")
269+
>>> facts = company.get_facts()
270+
>>> facts.get("us-gaap", "Revenue")
271+
"""
272+
273+
from edgar.models import Facts
274+
275+
raw = self.xbrl_facts()
276+
if raw is None:
277+
return None
278+
return Facts(raw=raw)

edgar/models.py

Lines changed: 249 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,7 @@ def recent_filings(self) -> list[dict]:
137137
return []
138138

139139
num_rows = len(recent[keys[0]])
140-
return [
141-
{key: recent[key][i] for key in keys}
142-
for i in range(num_rows)
143-
]
140+
return [{key: recent[key][i] for key in keys} for i in range(num_rows)]
144141

145142
@property
146143
def recent_submissions(self) -> list[Submission]:
@@ -153,7 +150,9 @@ def recent_submissions(self) -> list[Submission]:
153150

154151
def __repr__(self) -> str:
155152
ticker_str = ", ".join(self.tickers[:3]) if self.tickers else "N/A"
156-
return f"<CompanyInfo name={self.name!r} cik={self.cik!r} tickers={ticker_str!r}>"
153+
return (
154+
f"<CompanyInfo name={self.name!r} cik={self.cik!r} tickers={ticker_str!r}>"
155+
)
157156

158157

159158
@dataclass(frozen=True)
@@ -222,3 +221,248 @@ def size(self) -> int:
222221

223222
def __repr__(self) -> str:
224223
return f"<Submission form={self.form!r} date={self.filing_date!r} accession={self.accession_number!r}>"
224+
225+
226+
@dataclass(frozen=True)
227+
class Fact:
228+
"""A single XBRL fact data point.
229+
230+
Each fact represents one reported value for a specific concept,
231+
unit, and period.
232+
233+
### Usage
234+
----
235+
>>> facts = edgar_client.company("AAPL").get_facts()
236+
>>> revenue = facts.get("us-gaap", "Revenue")
237+
>>> revenue[0].value
238+
274515000000
239+
"""
240+
241+
raw: dict = field(repr=False)
242+
243+
@property
244+
def end(self) -> str:
245+
"""The period end date."""
246+
return self.raw.get("end", "")
247+
248+
@property
249+
def start(self) -> str:
250+
"""The period start date (empty for instant facts)."""
251+
return self.raw.get("start", "")
252+
253+
@property
254+
def value(self):
255+
"""The reported numeric value."""
256+
return self.raw.get("val")
257+
258+
@property
259+
def accession_number(self) -> str:
260+
"""The filing accession number."""
261+
return self.raw.get("accn", "")
262+
263+
@property
264+
def fiscal_year(self) -> int:
265+
"""The fiscal year."""
266+
return self.raw.get("fy", 0)
267+
268+
@property
269+
def fiscal_period(self) -> str:
270+
"""The fiscal period (e.g. ``'FY'``, ``'Q1'``, ``'Q2'``)."""
271+
return self.raw.get("fp", "")
272+
273+
@property
274+
def form(self) -> str:
275+
"""The form type that reported this fact (e.g. ``'10-K'``)."""
276+
return self.raw.get("form", "")
277+
278+
@property
279+
def filed(self) -> str:
280+
"""The date the filing was submitted."""
281+
return self.raw.get("filed", "")
282+
283+
@property
284+
def frame(self) -> str:
285+
"""The XBRL frame identifier (e.g. ``'CY2020Q4I'``), if present."""
286+
return self.raw.get("frame", "")
287+
288+
def __repr__(self) -> str:
289+
return (
290+
f"<Fact end={self.end!r} value={self.value!r}"
291+
f" form={self.form!r} fy={self.fiscal_year}>"
292+
)
293+
294+
295+
@dataclass(frozen=True)
296+
class Facts:
297+
"""Structured wrapper around the SEC EDGAR company_facts XBRL response.
298+
299+
Navigates the deeply nested ``facts`` JSON (4 levels deep) and
300+
provides convenient access by taxonomy and concept name.
301+
302+
### Usage
303+
----
304+
>>> facts = edgar_client.company("AAPL").get_facts()
305+
>>> facts.entity_name
306+
'Apple Inc.'
307+
>>> revenue = facts.get("us-gaap", "Revenues")
308+
>>> revenue[0].value
309+
274515000000
310+
>>> facts.taxonomies
311+
['dei', 'us-gaap']
312+
>>> facts.concepts("us-gaap")
313+
['AccountsPayableCurrent', 'AccountsReceivableNetCurrent', ...]
314+
"""
315+
316+
raw: dict = field(repr=False)
317+
318+
@property
319+
def cik(self) -> int:
320+
"""The CIK number."""
321+
return self.raw.get("cik", 0)
322+
323+
@property
324+
def entity_name(self) -> str:
325+
"""The entity name as reported in XBRL."""
326+
return self.raw.get("entityName", "")
327+
328+
@property
329+
def taxonomies(self) -> list[str]:
330+
"""List of taxonomy namespaces present (e.g. ``['dei', 'us-gaap']``)."""
331+
return list(self.raw.get("facts", {}).keys())
332+
333+
def concepts(self, taxonomy: str = "us-gaap") -> list[str]:
334+
"""List of concept names within a taxonomy.
335+
336+
### Parameters
337+
----
338+
taxonomy : str (optional, Default=``"us-gaap"``)
339+
The taxonomy namespace.
340+
341+
### Returns
342+
----
343+
list[str]:
344+
Sorted list of concept names.
345+
"""
346+
return sorted(self.raw.get("facts", {}).get(taxonomy, {}).keys())
347+
348+
def get(
349+
self,
350+
taxonomy: str,
351+
concept: str,
352+
unit: str | None = None,
353+
) -> list[Fact]:
354+
"""Retrieves fact data points for a given taxonomy/concept pair.
355+
356+
### Parameters
357+
----
358+
taxonomy : str
359+
The taxonomy namespace (e.g. ``"us-gaap"``, ``"dei"``,
360+
``"ifrs-full"``).
361+
362+
concept : str
363+
The concept tag name (e.g. ``"Revenue"``,
364+
``"AccountsPayableCurrent"``).
365+
366+
unit : str | None (optional, Default=None)
367+
If provided, returns only facts in this unit of measure
368+
(e.g. ``"USD"``, ``"shares"``). If ``None``, returns
369+
facts from all units combined.
370+
371+
### Returns
372+
----
373+
list[Fact]:
374+
A flat list of ``Fact`` objects, sorted by end date.
375+
"""
376+
concept_data = self.raw.get("facts", {}).get(taxonomy, {}).get(concept, {})
377+
if not concept_data:
378+
return []
379+
380+
units_data = concept_data.get("units", {})
381+
382+
results: list[dict] = []
383+
if unit is not None:
384+
results = units_data.get(unit, [])
385+
else:
386+
for entries in units_data.values():
387+
results.extend(entries)
388+
389+
results.sort(key=lambda d: d.get("end", ""))
390+
return [Fact(raw=entry) for entry in results]
391+
392+
def label(self, taxonomy: str, concept: str) -> str:
393+
"""Returns the human-readable label for a concept.
394+
395+
### Parameters
396+
----
397+
taxonomy : str
398+
The taxonomy namespace.
399+
400+
concept : str
401+
The concept tag name.
402+
403+
### Returns
404+
----
405+
str:
406+
The label string, or empty string if not found.
407+
"""
408+
return (
409+
self.raw.get("facts", {})
410+
.get(taxonomy, {})
411+
.get(concept, {})
412+
.get("label", "")
413+
)
414+
415+
def description(self, taxonomy: str, concept: str) -> str:
416+
"""Returns the description for a concept.
417+
418+
### Parameters
419+
----
420+
taxonomy : str
421+
The taxonomy namespace.
422+
423+
concept : str
424+
The concept tag name.
425+
426+
### Returns
427+
----
428+
str:
429+
The description string, or empty string if not found.
430+
"""
431+
return (
432+
self.raw.get("facts", {})
433+
.get(taxonomy, {})
434+
.get(concept, {})
435+
.get("description", "")
436+
)
437+
438+
def units(self, taxonomy: str, concept: str) -> list[str]:
439+
"""Returns the available units of measure for a concept.
440+
441+
### Parameters
442+
----
443+
taxonomy : str
444+
The taxonomy namespace.
445+
446+
concept : str
447+
The concept tag name.
448+
449+
### Returns
450+
----
451+
list[str]:
452+
List of unit names (e.g. ``["USD", "USD-per-shares"]``).
453+
"""
454+
return list(
455+
self.raw.get("facts", {})
456+
.get(taxonomy, {})
457+
.get(concept, {})
458+
.get("units", {})
459+
.keys()
460+
)
461+
462+
def __repr__(self) -> str:
463+
tax_count = len(self.taxonomies)
464+
total = sum(len(self.concepts(t)) for t in self.taxonomies)
465+
return (
466+
f"<Facts entity={self.entity_name!r} cik={self.cik}"
467+
f" taxonomies={tax_count} concepts={total}>"
468+
)

0 commit comments

Comments
 (0)