Files
OpenBB/openbb_platform/providers/sec/tests/test_statement_schema.py
Danglewood bbf1ab2020 V5 (#7489)
* [Feature] OpenBB V5: Initial `openbb-core` Refactor (#7482)

* initial refactor

* ruff does not dictate style

* unresolved imports in test action

* fix general linting

* resolve pandas imports for actions run

* httpx in test workflow

* pytest-asyncio in test workflow

* pandas.Index(...)

* max version for windows CI is 3.14

* remove temp refactor files that shouldn't have been committed:

* use ruff format instead of black

* formatting and typing

* tab space helper

* test fixtures..

* ty check

* also run integration test in CI

* remove that annoying labeler action

* [V5] `openbb-core`: 100% Test Coverage (#7484)

* increase test coverage to exercise 100% of the testable code path in openbb-core

* model_fields syntax cleanup

* set the environment variable for CI runs

* run codeql on v5 branch

* more CI env setup

* cleanup redundant files

* ruff

* dedupe workflow trigger

* some windows coverage missing

* ubuntu integration test job

* more windows coverage

* test_reference_generator

* test_docstring_generator

* remove line no references from test function docstrings

* clean useless comments from test files

* fix polars type check

* fix test_obbject

* fix test_obbject

* package builder fixes

* charting pyproject version

* fix docstring generator for nested models

* missing coverage

* [V5] `openbb-core`: Add Backwards-Compatible Layered Config System (openbb.toml) (#7492)

* add backwards-compatible layered config system

* fix path reference in windows test

* need python version-aware handling in docstring generator

* update for deprecated pydantic syntax

* ruff UP007

* test_system_settings

* remove v4 platform workflow

* [V5] Refactor `openbb-oecd` for full SDMX 2 API Exposure and V5 Packaging (#7413)

* stash some changes

* add more robust testing

* mypy

* point PR at V5

* introduce spec file

* codespell

* test fix

* fix workflow environment setup

* fix workflow environment setup

* fix workflow environment setup

* add pyyaml to dependencies

* split lint jobs

* fix workflow environment setup

* fix workflow environment setup

* workflow env setup

* workflow env setup

* clean up code comments

* add auth hook entrypoints

* codespell

* add codegen feature

* codespell

* move _unpack into dispatchers for consistency with codegen packages

* surface nested models in the response

* fix missing coverage in CI

* socrata updates

* test fix

* detect plotly output

* add --include and --exclude flags from generate-extension command

* refactor openbb-platform-api for V5 operations and spec file handling

* delete poetry.lock

* add test workflow

* add temp branch id for test workflow

* add httpx to dev dependencies

* add a conftest.py

* tweak conftest

* tweak conftest

* python 3.10 issues

* more python 3.10

* more python 3.10

* more python 3.10

* more python 3.10

* fix url template resolution

* multi-spec support

* fix a windows path issue

* merge branch develop

* table builder, integration tests, and more

* linting

* merge branch develop

* bit of cleanup

* some more updates, cleanup, and apps.json

* codespell

* black

* integration test params and linting

* mypy

* pylint

* black

* more pylint

* recapture teest cassettes

* black again

* more linting

* table presentation choices

* black

* fix merge conflict

* split up metadata module and reformat cache to use msgpack

* mypy

* regenerate lock file

* use json and include dependencies to make openbb-oecd a standalone install

* pylint

* more linting

* serve metadata as dependency instead of assign and initialize

* fix integration_tests_testers to not count dependency injections as paramters

* add assets to git ignore

* ruff

* v5 refactor and publish workflow

* test coverage and ty fixes

* codespell

* update lock file

* workflow setup

* ty check

* workflow setup

* Scope PR to OECD changes only

Restore non-OECD files in the diff against v5 to their v5 state. The
branch was originally created off feature/cli-2 and inherited 186 files
of unrelated changes (cli, agent_server, mcp_server, platform_api,
core/, .github/workflows/* not oecd). Those have nothing to do with the
openbb-oecd v5 refactor and were polluting the PR diff.

* scope cleanup: include leaked v5-resident files (general-linting workflow, platform_api test fixtures)

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>

* fix docstring generator and body param unwrapping

* project ty.toml file

* unpack POST parameters from params: Model

* remove comment chatter

* [V5] `openbb-oecd`: Rename `oecd_utils` as `oecd` (#7509)

* rename oecd_utils to oecd and populate all models there when openbb-economy is not installed

* linting

* bump dependencies

* fix test drift

* linter

* FieldInfo import

* [V5] `openbb-imf`: Refactor for V5 (#7508)

* refactor imf extension for V5

* codespell

* fix some python 3.10 items

* return self on copy of metadata object

* [V5] `openbb-news`: Refactor News for V5 and Add Configurable RSS Endpoint (#7507)

* refactor news for v5 and add rss endpoint

* remove poetry.lock

---------

Co-authored-by: deeleeramone <>

* [V5] `openbb-quantitative`: Refactor for V5 (#7506)

* refactor quantitative for v5

* add factors endpoint and add a few charts

---------

Co-authored-by: deeleeramone <>

* [V5] `openbb-technical`:  Refactor for V5 & Add More Quick Win Endpoints (#7502)

* refactor openbb-technical for v5

* forward refs

* linting

* no python 3.15 in test matrix

* reame

* ty

* move all params to body

---------

Co-authored-by: deeleeramone <>

* remove stockgrid provider (#7486)

* [V5] remove wsj provider and related code (#7485)

* remove wsj provider and related code

* purge from metadata

* actions

* actions

* openbb-core publish workflow

* delete biztoc (#7523)

* [V5] Remove `openbb-alpha-vantage` (#7522)

* delete alpha vantage

* more files

---------

Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>

* [V5] Remove `openbb-benzinga` (#7521)

* delete benzinga

* more references

---------

Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>

* [V5] `openbb-bls`: Refactor for V5 + Add Many Endpoints (#7518)

* refactor bls for v5, add 100 widgets

* some CI fixes

* no python 3.15

* fix missing dev group in pyproject.toml

* more pyproject fallout

* action

* refactor econometrics for v5 (#7505)

Co-authored-by: deeleeramone <>

* [V5] `openbb-famafrench`: Refactor for V5 (#7503)

* refactor famafrench for v5

* codespell

* finishing touches

---------

Co-authored-by: deeleeramone <>

* [V5] CLI 2.0 - Pluggable Backends, Non-TTY Default, HTTP Dispatcher, Spec Files + Codegen (#7433)

* stash some changes

* add more robust testing

* mypy

* point PR at V5

* introduce spec file

* codespell

* test fix

* fix workflow environment setup

* fix workflow environment setup

* fix workflow environment setup

* add pyyaml to dependencies

* split lint jobs

* fix workflow environment setup

* fix workflow environment setup

* workflow env setup

* workflow env setup

* clean up code comments

* add auth hook entrypoints

* codespell

* add codegen feature

* codespell

* move _unpack into dispatchers for consistency with codegen packages

* surface nested models in the response

* fix missing coverage in CI

* socrata updates

* test fix

* detect plotly output

* add --include and --exclude flags from generate-extension command

* cap test matrix at python 3.14

* no useless comments

* platform controller command description split

* merge URL overloads from path params

* exclude none and unset from model dump

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>

* general-linting

* downgrade aiohttp in devtools for vcrpy

* some ty items and add requests-types stubs for ty in devtools

* markdown linting

* remove integration tests from extensions that were moved into the famafrench provider

* [V5] `openbb-congress-gov`: Refactor for V5 + Convert To Using Bulk Files Instead of `api.congress.gov`. (#7528)

* refactor congress-gov for v5 and use bulk files instead of API with credentials

* integration tests

* apps tab name

* convert caching to sql

* codespell

* [V5] [BugFix] Fix for Breaking Changes in FastAPI 0.137+ (#7537)

* fix route introspection for fastapi 0.137

* recapture oeccd gdp forecast test cassette

---------

Co-authored-by: deeleeramone <>

* [V5] `openbb-platform-api`: Refactor for V5 Spec, Config, Packaging, Test Coverage (#7494)

* stash some changes

* add more robust testing

* mypy

* point PR at V5

* introduce spec file

* codespell

* test fix

* fix workflow environment setup

* fix workflow environment setup

* fix workflow environment setup

* add pyyaml to dependencies

* split lint jobs

* fix workflow environment setup

* fix workflow environment setup

* workflow env setup

* workflow env setup

* clean up code comments

* add auth hook entrypoints

* codespell

* add codegen feature

* codespell

* move _unpack into dispatchers for consistency with codegen packages

* surface nested models in the response

* fix missing coverage in CI

* socrata updates

* test fix

* detect plotly output

* add --include and --exclude flags from generate-extension command

* refactor openbb-platform-api for V5 operations and spec file handling

* delete poetry.lock

* add test workflow

* add temp branch id for test workflow

* add httpx to dev dependencies

* add a conftest.py

* tweak conftest

* tweak conftest

* python 3.10 issues

* more python 3.10

* more python 3.10

* more python 3.10

* more python 3.10

* fix url template resolution

* multi-spec support

* fix a windows path issue

* cap test matrix at python 3.14

* no 3.15 runners for platform-api

* no useless comments

* platform controller command description split

* merge URL overloads from path params

* exclude none and unset from model dump

* markdown lint

* ignore get/post endpoints that are return SSE

* fix remaining fastapi 0.137 fallout

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>

* add full support for AsyncIterator response type for streaming output (#7530)

* [V5] Refactor Shared Pass Through Router Extensions For Hatchling (#7524)

* refactor shared pass-through routers for hatchling and ruff + ty

* add publish workflows

* [V5] `openbb-charting`: Refactor for V5 and Decouple  (#7545)

* refactor charting for v5 patterns and decouple entirely

* coveragerc

* lint and tests

* fix tests

* configure dev group with openbb-technical

* ty

* fix cli test

* remove 3.15 from test matrix - dependencies are unstable

---------

Co-authored-by: deeleeramone <>

* fix merge widgets

* workflow cleanup"

* fixes for ty

* fogot that file

* general lint setup

* consolidate lint job

* remove seeking alpha provider (#7570)

* remove tradier provider (#7573)

* remove tiingo provider (#7574)

* remove intrinio provider (#7575)

Remove the openbb-intrinio provider extension from the repository.
Reason: proprietary vendor with no internal access to any endpoint;
cannot build, develop or maintain.

* remove yfinance provider from repo (#7576)

* fix(cli): resolve modular OpenAPI references (#7586)

Fixes #7585

* [V5] `openbb-cli`: Modular OpenAPI Refs (#7589)

* follow up to #7585

* add another codegen test

* unused ty ignore

* Update generated tests to get python parameter names instead of raw API-provider parameter names. (#7597)

* Update the record-vs-envelope heuristic in the payload processing code to not treat a record that happens to have a list datatype as an entire envelope. (#7598)

* Update class generation code to handle empty data that php-style serialization emits as []. (#7599)

* fix ty check failures exposed by newer version

* add drugs.com and brutalist as news rss feeds (#7602)

Co-authored-by: deeleeramone <>

* codespell

* missing test coverage that appeared in famafrench..

* plotly 7 handling for mapbox namespace change

* remove finviz (#7577)

* remove trading economics provider (#7578)

* remove openbb-multpl from repository (#7610)

* add httpx2 to devtools for starlette test client

* restore test coverage to 100% in openbb_core

* remove fmp provider extension from repository (#7665)

* unpin ruff

* fix charting conftest and missing coverage in 3.10

* add charting coverage

* fix new ty issues

* fix tag grouping in swagger docs

* [V5] `openbb-ecb`: Refactor for V5 and Expand Coverage (#7560)

* refactor openbb-ecb for v5 and expand coverage

* some updates

* tests

* workflow

* remove 3.15 from test matrix

* some more table schema updates

* remove some html chrome on embedded articles

---------

Co-authored-by: deeleeramone <>

* [V5] `openbb-federal-reserve`: Refactor for V5 + FFIEC & Regional Fed Routers (#7561)

* refactor for v5

* add workflows

* fix bhcpr schema

* fix pyproject.toml

* fix test

* codespell

* ty

* fix pyproject

* fix ci environment

* fix ci environment

* convert documents endpoints to use fedinprint service

* codeql fix

* fix ny publications and add ny nowcast

* [V5] `openbb-eia`: Refactor for V5 + Add Full API Implementation (#7584)

* eia refactor for v5

* add test and publish workflows

* data browser state

* fix workflows

* more workflow test items

* temp files in tests

* more fixes

* more browsers

* fix codeql issues

* fix mer state and ensure multi-user capability

* uv lock

* table bridge

* fix electricity browser state

* convert HTML widgets to iFrame

* fix recursion error in workspace mcp

* [V5] `openbb-government-us`: Refactor for V5 + Move `uscongress` (#7604)

* v5 government_us provider

* workflow files

* ty and add xlrd to pyproject

* shrink test cassettes

* dev dependencies

* wheel packaging and more tests

* python 3.10 thing..?

* fix test

* default state for multiselect dropdowns

* replace congress html widgets with iframes

* fallback to congress api for congress before 108

* ensure member passage voting count populates

* [V5] Feature: Add JODI World Oil & Gas Database (#7593)

* add the JODI oil and gas database as a provider/router extension

* lint fixes

* add grouping to apps.json

---------

Co-authored-by: deeleeramone <>

* [V5] `openbb-cftc`: Refactor for V5 + Add OIS, Forward Curve, and FX Trade Analysis (#7615)

* refactor openbb-cftc for v5

* add SHIBOR to fixings

---------

Co-authored-by: deeleeramone <>

* fix for plotly 7 breaking change to schemas

* [V5] `openbb-tmx`: Refactor extension for V5 (#7668)

* refactor tmx provider for v5

* fix tests..

* fix tests stack trace exposure

* fix workflow setup

* [V5] `openbb-nasdaq`: Refactor provider for V5. (#7670)

* refactor nasdaq provider for v5

* add workflows

* ty fix

* [V5] `openbb-fred`: Refactor as V5 extension. (#7669)

* refactor fred extension for v5

* fix markdown MD037 lint error in README

* codespell

* [V5] `openbb-cboe`: Refactor for V5 + Add Payoff Diagrams (#7617)

* refactor cboe for v5

* use zoneinfo instead

* fix test

* some fixes related to the cache access

* fix a couple of tests

* fix the test coverage

* [V5] `openbb-deribit`: Refactor provider for V5 (#7674)

* refactor openbb-deribit for v5

* restructure the optimizer around the published combos data and add integration test suites

* fix markdown lint errors

* remove openbb-econdb provider (#7675)

* codespell

* [V5] `openbb-sec`: Refactor for V5 (#7525)

* refactor openbb-sec for v5

* linting

* ty

* normalize compare_company_facts

* add workflows

* python 3.10 fix

* add more thorough url validation and tests

* ty check

* ty for python 3.10

* reconfigure with apps

* temp pyproject overrride

* pyproject

* pyproject

* pyproject

* codespell

* pyproject

* fix uk schema file links in schema_files

* test coverage

* test_cli

* port #7547 into this pr

* no hard coded prefix

* improve filing rendering capabilities

* fix xml text detection

* make codeql happy

* missed a file in the commit

* test coverage

* codespell

* fix flakey test

* some updates

* management_ownership --> management_profiles

* proxy statement markdown

* ruff

* fix test coverage

* more tests

* test coverage

* fix lint errors

* Add SEC adviser search, profile, and universe routes (#7559)

* Add SEC institutional data fetchers

* Add SEC filing index and Form D routes

* Add SEC investment adviser people data

* Expose SEC adviser and institutional routes

* fix(sec): address adviser review feedback

* refactor(sec): narrow adviser support to IAPD search

* feat(sec): add adviser firm and individual searches

* feat(sec): add adviser profile drill-down

* fix: repair pre-commit configuration

* feat(sec): add adviser universe route

* feat(sec): expose complete Form ADV data

---------

Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>

* fix test coverage

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>

* [V5] `openbb-finra`:  Refactor for V5 (#7676)

* refactor finra provider for v5

* add workflows

* fix tests

* fix lint errors

* [V5] Update Repository License to Apache 2.0 (#7677)

* change license to Apache 2.0 for V5

* codespell

* bump devtools and repoint pyproject.toml

* Enterprise Flask Migration Toolkit (#7264)

* feat: Add Flask-to-OpenBB enterprise migration toolkit

- Implement Router.from_flask() for zero-touch legacy API conversion
- Enable 4-hour migration vs 6-month traditional rewrite (80% cost reduction)
- Generate FastAPI endpoints from Flask routes with automatic type validation
- Support enterprise consulting services and custom integration workflows

Demonstrates OpenBB's competitive advantage for institutional client migrations.
Complements existing Workspace applications like Open Portfolio suite.

* chore: Resolve .gitignore conflict and add common ignores

* refactor: implement Phase 1 Flask integration architecture- Move Flask conversion logic to openbb_core.app.utils.flask- Add Flask detection to extension_loader - Integrate with OpenBB's core extension system- Add Flask dependency validation with sys.modules checkingAddresses @deeleeramone's architectural feedback for native OpenBB integration.

* docs: add Flask direct entry point demos- demo_direct_flask_entry_point.py: shows direct Flask instance reference- example_pyproject_flask_entry.toml: demonstrates pyproject.toml configurationSupports new architecture eliminating wrapper extensions.

* Implement Phase 1 Flask adapter per code review feedback

- Minimal working implementation with Flask detection and router creation
- Removes complex code generation logic for focused Phase 1 delivery
- Establishes foundation for Phase 2 route conversion features

* Fix Flask integration: Replace missing FlaskToOpenBBAdapter with WSGIMiddleware

- Remove FlaskToOpenBBAdapter which was not implemented
- Use FastAPI's WSGIMiddleware to mount Flask apps directly
- Update extension_loader to use returned Router object
- Convert utils.py to utils/__init__.py to support flask submodule
- Addresses PR review comment about unhandled AttributeError

* Address PR review feedback: fix Flask imports and remove unused files

- Revert .gitignore to upstream/develop
- Remove AI-generated demo/example files and flask_adapter extension
- Move Flask/werkzeug imports inside conditional blocks to avoid ImportError
- Use lazy imports via __getattr__ in utils/flask/__init__.py
- Add Flask tests with @pytest.mark.skipif markers

* Force APIRouter.include_router to propagate Mount paths instead of dropping them. (#1)

This change ensures that mounted ASGI applications (such as Flask apps wrapped in WSGIMiddleware) are not dropped when `APIRouter.include_router` is called.

Previously, while the initial load in `ExtensionLoader` worked as expected, the `Mount` objects were ignored by FastAPI's default inclusion logic. This caused the mounted elements to be lost during the final application assembly, resulting in 404 errors for the Flask routes.

This fix overrides the inclusion logic to manually propagate `Mount` objects downstream.

* feat(flask): Add metadata layer for OpenAPI documentation

Enhance Flask-to-OpenBB converter with structured docstring parsing:
- Parse Google-style docstrings (summary, description, params)
- Extract parameter descriptions from Args section
- Pass metadata to Router.command() for rich OpenAPI docs

This addresses PR feedback to provide tangible benefit by generating
comprehensive API documentation for migrated Flask routes.

* feat(flask): Implement manual OpenAPI spec generation and attachment

- Add FlaskParam and FlaskRouteInfo TypedDict for type-safe metadata
- Implement OpenAPISpecGenerator for Flask→OpenAPI conversion
- Extract route metadata via FlaskIntrospector
- Attach generated spec to router.openbb_openapi_spec_ext
- Add unit tests (3/3 passing)

Addresses WSGI/mount boundary issue where Router.command metadata
cannot propagate through WSGIMiddleware. Spec attached to router
instance for downstream OpenAPI assembly.

Resolves maintainer feedback on PR #7264

* feat(flask): Implement FlaskMountRegistry for multiple mount support

- Add FlaskMountRegistry class for centralized OpenAPI spec management
- Replace router.openbb_openapi_spec_ext with registry pattern
- Support multiple Flask apps at different mount prefixes
- Automatic path rewriting in aggregated OpenAPI specs
- Add registry test case (4/4 tests passing)

Addresses @deeleeramone's feedback on PR #7264 regarding router
attribute fragility and multiple mount support. Registry provides
lifecycle-independent state management and enables enterprise
scenarios with multiple Flask apps.

Resolves: OpenBB-finance/OpenBB#7264 (partial)

* feat(core): load Flask apps as openbb_core_extension WSGI sub-apps

A Flask app referenced directly by an openbb_core_extension entry point is
mounted via WSGIMiddleware and its routes are introspected into openapi.json
(operationId/widgetId/mcp_tool, parameters, request bodies, security, widget_config
and HTML widgets) so the widget factory and MCP server can discover them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(core): 100% coverage for flask utils + flask dev dependency

Add branch-coverage tests for the Flask integration package (introspection,
AST analysis, docstrings, OpenAPI generation, registry, loader, detection) and
declare flask as a dev dependency so the suite runs in CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(flask): treat f-string returns as HTML responses

* fix(flask): mount via a2wsgi instead of deprecated starlette WSGIMiddleware

- add openbb-core[flask] extra (flask, a2wsgi); a2wsgi resolved through require_optional
- run the WSGI serve test through mount_flask_extensions; drop httpx importorskip
- core CI installs [pandas,flask] and httpx2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(flask): cover failed entry point loads; narrow optional types for ty

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* perf(core): vectorize the date conversion in `basemodel_to_df` (#7622)

* perf(core): vectorize the date conversion in basemodel_to_df

basemodel_to_df ran three per-element Python passes over the date column of
every payload it converted. Series.apply(to_datetime) calls pandas.to_datetime
once per row, and because each call receives a lone scalar with no format hint,
pandas also re-runs its datetime format guesser on every row.

Parse the column in one call instead, and use vectorized comparisons for the
all-midnight test and the reduction to datetime.date.

The element-wise parse is kept as a fallback for one case that the vectorized
path cannot represent: a column mixing UTC offsets, such as a series crossing a
daylight-savings boundary. Parsing per element lets each value keep its own
offset and yields an object column of tz-aware Timestamps, whereas a single
to_datetime call over that column raises "Mixed timezones detected". That
behaviour is covered by test_to_df_daylight_savings and is preserved exactly.

Measured on a payload built through EquityHistoricalData, median of 9 under an
exclusive CPU lock:

  250 daily bars     8.92 ms -> 1.46 ms
  1,000 daily bars  32.93 ms -> 3.13 ms
  5,000 daily bars 162.25 ms -> 12.23 ms

* fix a broken test - unrelated to this PR...

---------

Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix lint errors

* fix oecd lint error

* [V5] `openbb-mcp-server`: Refactor for v5 - Spec, Config, Packaging, Test Coverage (#7495)

* stash some changes

* add more robust testing

* mypy

* point PR at V5

* introduce spec file

* codespell

* test fix

* fix workflow environment setup

* fix workflow environment setup

* fix workflow environment setup

* add pyyaml to dependencies

* split lint jobs

* fix workflow environment setup

* fix workflow environment setup

* workflow env setup

* workflow env setup

* clean up code comments

* add auth hook entrypoints

* codespell

* add codegen feature

* codespell

* move _unpack into dispatchers for consistency with codegen packages

* surface nested models in the response

* fix missing coverage in CI

* socrata updates

* test fix

* detect plotly output

* add --include and --exclude flags from generate-extension command

* refactor mcp server for v5

* test action

* fix tests

* tests

* test fixture name

* revert: drop unrelated provider/README changes from mcp-server refactor

Strip the changes to ``README.md`` and ``openbb_platform/providers/{imf,
tradingeconomics,yfinance}`` that snuck into 4ac3602 (refactor mcp server
for v5). Those files are out of scope for the mcp-server work and were
modified inadvertently — restore them to their pre-4ac3602 state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* tests and workflows

* fix workflow

* tests and workflows

* tests and workflows

* tests and workflows

* tests and workflows

* beartype shim for 3.15

* muck around with beartype for 3.15

* muck around with beartype for 3.15

* no 3.15 runners for mcp-server

* update skill files

* cap test matrix at python 3.14

* merge branch v5

* no useless comments

* platform controller command description split

* merge URL overloads from path params

* exclude none and unset from model dump

* refactor for fastmcp4

* wrap markdown lines that are too long

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* update pre-commit, noxfile, and uv locks

* fixes for docstring generation

* test coverage

* fix cli renderer helper with figure object JSON

* fix the figure font when rendered

* refactor cookiecutter

* bump cookiecutter packagge version

* fix sec test by adding fixture

* fix desktop environments test

* fix(cli): inherit path-item-level OpenAPI parameters (#7606)

* fix(cli): inherit path-item-level OpenAPI parameters

OpenAPI 3.x lets a Path Item Object declare `parameters` shared by every
operation under that path, with operation-level entries overriding them by
(name, in). The codegen read only `op.get("parameters")`, so inherited
parameters were dropped silently — the generated command kept the URL
template but had nothing registered to fill it.

Real-world impact: the Xero Accounting API declares its required
`xero-tenant-id` header once per path item across all 138 paths. Before this
change `build_command_spec` produced 78 commands and none carried the
header, so every generated command 403s. Nothing warned.

Adds `operation_parameters()`, which merges the two levels and dereferences
both, and routes the pre-computed spec path (`build_command_spec`) and the
interactive path (`build_command_index` -> `build_parser_from_operation`)
through it. Unresolvable refs, non-dict entries, and nameless parameters are
skipped as before. Specs without path-item parameters are unaffected.

* fix(cli): narrow path-item inheritance to path and query parameters

Per review: headers do not translate into a Python interface function, and
outbound headers are transport configuration
(SystemSettings.PythonSettings.http.headers, the CLI's -H) or a security-scheme
concern — not a per-command argument.

Path-item `in: header` and `in: cookie` entries are now dropped rather than
inherited. Operation-level parameters are untouched, so an operation that
declares its own header keeps today's behavior.

Scope is now only what cannot be supplied any other way: `in: path` and
`in: query` parameters declared on the Path Item Object.

* fix(cli): merge allOf compositions in response schemas

Response-schema $refs were already expanded by deref_schema, but a schema
whose top level is an allOf composition was left unflattened. The properties
live inside the members, so a consumer reading them off the composition finds
none. fetcher_gen._data_schema looks for `properties.results`, gets nothing,
and falls through to _unwrap_schema_envelopes, which handles oneOf and anyOf
but not allOf. The generated Data model ends up with no fields.

Codat's listBills response is `allOf: [{properties: {results}}, {properties:
{_links, pageNumber, pageSize, totalResults}}]`, which produced an empty model.

merge_allof collapses a composition of object subschemas into one object
schema: properties unioned with the first declaration winning on conflict,
required unioned, and sibling keywords on the allOf node outranking members.
A composition that is not a plain intersection of object subschemas (a member
carrying its own oneOf/anyOf, or a non-object type) is returned unchanged
rather than flattened into something the spec does not describe. Nested
compositions are merged too, so a $ref inside a merged member still resolves.

Applied to extract_response_schema and extract_response_schemas.

Measured across five specs that use the pattern, counting operations whose
response schema exposes readable properties: 244 of 588 before, 394 after
(Codat Accounting 36 to 88, Codat Banking 0 to 8, Codat Commerce 1 to 11,
Rebilly 62 to 104, Box 145 to 183).

* fix(cli): merge allOf compositions in request body schemas

Same gap as the response side. request_body_parameters needs `type: object`
or `properties` to flatten a body into parameters, and an allOf composition
carries neither, so a POST or PUT generated from one gets no body parameters
at all.

extract_request_body_schema now runs merge_allof after deref_schema, matching
extract_response_schema.

Across the same five specs, counting request bodies that produce parameters:
138 of 206 before, 177 after (Codat Accounting 1 to 26 of its 30 bodies,
Rebilly 50 to 63, Box 87 to 88).

* fix(cli): stop annotation keywords blocking an allOf merge

_mergeable_allof_members required every member's keys to come from a small
whitelist, so any unrecognized keyword disqualified the whole composition.
Codat's PagingInfo carries `definitions` next to its properties, which meant
the live Codat-Accounting.yaml never merged and its response models stayed
empty. The APIs.guru copy of the same API omits that key, which is why the
earlier measurements looked clean.

The check is now a blocklist. A member blocks the merge when it carries its
own combinator (oneOf/anyOf/allOf/not), an unresolved $ref left by a cycle,
a scalar or array constraint (enum/const/items), or a type other than object.
Annotation keywords that do not affect the intersection (title, description,
examples, definitions, additionalProperties, vendor x- keys) no longer block
it, and are dropped from the merged result along with the members.

On the live Codat spec, operations whose response schema exposes readable
properties go from 36 of 127 to 114. Across the five specs measured earlier:
244 of 588 before, 488 after (was 394 under the whitelist).

Corpus check over 2,816 response and request-body extractions in 6 specs:
567 changed, every one attributable to an allOf in the pre-merge output,
0 unexplained. Twilio declares no allOf compositions and is unchanged.

* fix(cli): emit a name once when it is both a parameter and a body property

_signature_params appended request-body properties and then operation
parameters without deduplicating, so a name declared in both places produced
two function parameters with the same name and the generated module raised
SyntaxError.

Codat's push endpoints hit this: /push/bankAccounts/{accountId}/bankTransactions
declares accountId as a path parameter and repeats it in the request body, so
connections.push.bankAccounts.bankTransactions generated `accountId: str` and
`accountId: str = None` in one signature.

The operation parameter wins, since it carries the path placeholder and its own
required flag. The request payload is unchanged: _render_body_block derives body
fields from request_body_schema rather than from this list, so accountId is
still sent in the body.

This predates the path-item and allOf work in this PR. On origin/v5 the same
spec produces the same duplicate; on this branch the Codat spec generates 91
commands with 0 duplicated signatures, down from 1.

* fix(cli): use the safe identifier for keyword-named body properties

_render_body_block already writes `{'from': from_}`, keying the payload by the
wire name and reading a safe identifier, but _signature_params declared the raw
name. A body property named after a Python keyword therefore emitted
`from: str = None` and the generated module raised SyntaxError.

This did not fire before the allOf work in this PR, because a composed request
body produced no body parameters at all. Codat's Transfer body is such a
composition and has a `from` property, so connections.push.transfers started
generating invalid syntax once the body flattened.

_signature_params now emits safe_field_name(name)[0], the same helper
fetcher_gen and pydantic_gen already use. The body/query split in
_render_body_block compares against the safe names too: it previously matched
signature names against wire names, so `from_` was not recognized as a body
field and leaked into the query string as a `from_` parameter.

End to end on Codat-Accounting.yaml, generating the full extension and byte
compiling every emitted module: 136 files, 0 failures. origin/v5 emits 1 file
that does not compile (duplicate argument 'accountId').

Known gap left alone: an operation parameter named after a keyword would still
break, since the query and path lines use the raw name as a local variable.
None of the five specs checked declares one.

* fix(cli): do not treat instance data as schema, and sanitize POST identifiers

Two defects found by generating extensions from real specs and byte compiling
every emitted module.

merge_allof and deref_schema walked every value in a schema dict, including
keywords that hold instance data rather than subschemas. A `default` of
`{"allOf": [{"a": 1}, {"b": 2}]}` was rewritten to `{"type": "object"}`, and a
`$ref` key inside an example was resolved as if it were a reference. Both now
copy `default`, `example`, `examples`, `enum`, `const` and vendor `x-` values
through untouched.

_signature_params emitted operation parameter names verbatim, so a wire name
that is not a Python identifier produced invalid syntax. Rebilly declares an
`Organization-Id` header and a `REB-APIKEY` security header, which emitted
`REB-APIKEY: str = None`. It now uses safe_field_name, matching fetcher_gen,
and _render_body_block keys the query dict by the wire name while reading the
safe identifier so the request is unchanged.

This surfaced through the allOf work rather than being caused by it: a composed
request body used to yield no properties, so `has_body` was false in
package_gen and the command was generated as a GET fetcher, where identifiers
were already sanitized. With bodies flattening, 14 of Codat's 24 POST endpoints
correctly move to the POST path, which had never been exercised by these specs.

Generating the full extension and compiling every module:

  Codat-Accounting.yaml   v5: 1 failure    branch: 0   (136 files)
  Rebilly 2.1             v5: 14 failures  branch: 1   (97 files)

The one remaining Rebilly failure is `async def 3dsecure(` in the router, which
fails identically on v5. Sanitizing that is a naming decision about the command
surface, so it is left alone here.

Known gap: a non-credential header parameter is still placed in the query
string on the POST path. Pre-existing, and unchanged by this PR.

* fix(cli): resolve signature collisions on the emitted identifier

The previous dedupe compared wire names, but the failure it prevents depends on
the sanitized identifier. Two wire names can collapse onto one (`from` and
`from_`, `Organization-Id` and `Organization_Id`), and operation_parameters
keeps a name declared twice under different `in` locations by design, so both
cases could still emit a duplicate argument.

_signature_params now tracks the identifiers it has emitted and skips anything
that would repeat one. Emission order is unchanged, so existing signatures are
byte-identical; only genuinely colliding names are dropped, and operation
parameters still win over body properties.

Neither collision occurs in Codat-Accounting.yaml or Rebilly 2.1. This closes
the class rather than the instance, since the instances are what this PR has
been finding.

Generation unchanged: Codat 136 files 0 failures, Rebilly 97 files 1 failure
(`async def 3dsecure(`, identical on v5).

* test(cli): cover the instance-data guard, and drop a redundant branch

Mutation checking the new tests (revert each fix, confirm its test fails)
found two problems with the previous commits.

The guard that stops merge_allof and deref_schema from walking into `default`,
`example`, `enum`, `const` and vendor values shipped with no test at all. It is
now covered from both directions: a default holding an `allOf` key keeps its
value, and a `$ref` inside a default stays a literal while a real reference
beside it still resolves.

The body-property loop also carried a redundant check. It tested the raw wire
name against the operation parameter names before testing the sanitized one,
but `operation_param_names` holds both forms and `safe_field_name` is idempotent
on identifiers, so the first test could never fire alone. Reverting it left
every test passing, which is what surfaced it. Removed, and the sanitized name
is now computed once.

All nine mutations are caught by the suite: both data guards, the allOf
blocklist in each direction, the response merge wiring, body/parameter
precedence, the keyword identifier, the body/query split and the
emitted-identifier dedupe.

cli/tests at 1,954 passed against the same 80 environment failures as the base.
Generation unchanged: Codat 136 files 0 failures, Rebilly 97 files 1 failure.

* fix(cli): route required path-item headers/cookies through credential handling

* fix(cli): narrow Popen.stdin/stdout for ty in run_in_obb fixture

---------

Co-authored-by: deeleeramone <>

* fix warnings raised by federal_reserve on some xlsx workbook parsing

* fix publish workflows

* test publish pipeline with openbb-core-v2.0.0rc0 (#7681)

* fix publish workflows with missing tag job, some tests and bump fastapi

* fix python 3.10 test failure in core

* actually fix python 3.10 test failure in core

* deincrement openbb-core version to allow release pattern

* bump core version (#7682)

* remove uv.sources from pyproject.toml files and point to openbb-core-2.0.0 on PyPI

* remove oecd cache asset that wasn't supposed to be commuitted

* remove poetry.lock, update pyproject.tml and uv.lock for openbb-platform-api-v2.0.0 (#7684)

Co-authored-by: deeleeramone <>

* add the missing publish-openbb-platform-api.yml workflow file

* remove poetry.lock, update pyproject.tml and uv.lock for openbb-platform-api-v2.0.0 (#7685)

Co-authored-by: deeleeramone <>

* fix the tag generation after publish

* [Release] `openbb-charting-v4.0.0` (#7686)

* up openbb-charting to 4.0.0 and fill missing test coverage in chart_style

* test coverage and format markdown files

---------

Co-authored-by: deeleeramone <>

* remove scikit-learn from dependencies, update tests, and format markdown files (#7687)

Co-authored-by: deeleeramone <>

* [Release] `openbb-cli-v2.0.0` (#7692)

* fix missing test coverage in openbb_cli

* fix conftest

---------

Co-authored-by: deeleeramone <>

* add missing publish-openbb-cli.yml workflow

* [Release] `openbb-cli-v2.0.0` (#7693)

* fix missing test coverage in openbb_cli

* fix conftest

---------

Co-authored-by: deeleeramone <>

* test fixes for cli...

* [Release] `openbb-cli-v.2.0.0` (#7694)

* fix missing test coverage in openbb_cli

* fix conftest

---------

Co-authored-by: deeleeramone <>

* fix hanging mcp server test

* [Release] `openbb-mcp-server-v2.0.0` (#7695)

Co-authored-by: deeleeramone <>

* update uv.lock (#7696)

Co-authored-by: deeleeramone <>

* release openbb-commodity-v2.0.0 (#7697)

Co-authored-by: deeleeramone <>

* update uv.lock (#7698)

Co-authored-by: deeleeramone <>

* update uv.lock (#7699)

Co-authored-by: deeleeramone <>

* update uv.lock (#7700)

Co-authored-by: deeleeramone <>

* [Release] `openbb-econometrics-v2.0.0` (#7701)

* update uv.lock

* update pyproject.toml

---------

Co-authored-by: deeleeramone <>

* update uv.lock (#7703)

Co-authored-by: deeleeramone <>

* update uv.lock (#7702)

Co-authored-by: deeleeramone <>

* update uv.lock (#7704)

Co-authored-by: deeleeramone <>

* update uv.lock (#7705)

Co-authored-by: deeleeramone <>

* update uv.lock (#7706)

Co-authored-by: deeleeramone <>

* [V5] Fix a Number of V5 Bugs (#7707)

* fix a number of v5 bugs

* fix lint error

* more lint

* fixes for quantitative, technical, charting, famafrench for version bumps

---------

Co-authored-by: deeleeramone <>

* update uv.lock (#7709)

Co-authored-by: deeleeramone <>

* update uv.lock (#7708)

Co-authored-by: deeleeramone <>

* update uv.lock (#7710)

Co-authored-by: deeleeramone <>

* update uv.lock (#7711)

Co-authored-by: deeleeramone <>

* [Release] `openbb-cboe-v2.0.0` (#7713)

Co-authored-by: deeleeramone <>

* update uv.lock (#7714)

Co-authored-by: deeleeramone <>

* update uv.lock (#7717)

Co-authored-by: deeleeramone <>

* [Release] `openbb-ecb-v2.0.0` (#7715)

* update uv.lock

* update uv.lock

---------

Co-authored-by: deeleeramone <>

* update uv.lock (#7718)

Co-authored-by: deeleeramone <>

* update uv.lock (#7719)

Co-authored-by: deeleeramone <>

* update uv.lock (#7720)

Co-authored-by: deeleeramone <>

* add missing publish-openbb-fred.yml to repo

* update uv.lock (#7723)

Co-authored-by: deeleeramone <>

* update uv.lock (#7722)

Co-authored-by: deeleeramone <>

* update uv.lock (#7716)

Co-authored-by: deeleeramone <>

* update uv.lock (#7724)

Co-authored-by: deeleeramone <>

* update uv.lock (#7726)

Co-authored-by: deeleeramone <>

* [Release] `openbb-fred-v2.0.0` (#7725)

* update uv.lock

* fix tests and dependencies

* fix test_fred_fetchers

---------

Co-authored-by: deeleeramone <>

* [Release] `openbb-imf-v3.0.0` (#7727)

* update uv.lock

* fix tests and dependencies

* fix test_fred_fetchers

* bump openbb-imf version to 3.0.0

---------

Co-authored-by: deeleeramone <>

* update uv.lock (#7729)

Co-authored-by: deeleeramone <>

* update uv.lock (#7728)

Co-authored-by: deeleeramone <>

* update uv.lock (#7730)

Co-authored-by: deeleeramone <>

* update uv.lock (#7731)

Co-authored-by: deeleeramone <>

* update uv.lock (#7732)

Co-authored-by: deeleeramone <>

* update uv.lock (#7733)

Co-authored-by: deeleeramone <>

* cookiecutter pyproject and lock

* update pyproject.toml (#7734)

Co-authored-by: deeleeramone <>

* update pyproject.toml (#7735)

Co-authored-by: deeleeramone <>

* update uv.lock (#7736)

Co-authored-by: deeleeramone <>

* fix some sec issues, allegedly (#7737)

Co-authored-by: deeleeramone <>

* update uv.lock (#7738)

Co-authored-by: deeleeramone <>

* update main pyproject.toml

* lint fixes

* update assets (#7739)

Co-authored-by: deeleeramone <>

* [Release] `openbb-v5.0.0` (#7740)

* update assets

* add missing workflow

---------

Co-authored-by: deeleeramone <>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
Co-authored-by: Dex <i@dex.moe>
Co-authored-by: Monte Hoover <monte.b.hoover@gmail.com>
Co-authored-by: Boris Li <65254053+BorisQuanLi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Lucas Zimmermann <lucaszmayer@gmail.com>
2026-09-29 14:45:53 +00:00

3559 lines
128 KiB
Python

"""Unit tests for the statement_schema engine.
These tests drive the pure functions in
``openbb_sec.utils.statement_schema`` directly with small synthetic
inputs to exercise statement-type detection, schema lookups, and
row-level extraction. A real BlackRock company-facts fixture is also
pushed end-to-end (annual, quarterly, and growth periods) to cover the
combinatorial extraction/imputation branches that only realistic data
reaches. The imputation engine's own unit tests live in
``test_imputation.py``.
Tests only — no source under ``openbb_sec/`` is modified.
"""
# flake8: noqa: D101,D102,D103,D403
import json
from collections import Counter
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
from openbb_sec.utils.company_facts import resolve_company_facts
from openbb_sec.utils.statement_schema import StatementSchema
from openbb_sec.utils.statement_schema._detection import (
_collapse_near_dates,
_fiscal_position,
_has_recent_data,
_latest_annual_shares,
annual_fiscal_years,
detect_reporting_currency,
detect_type,
get_filing_dates,
get_fiscal_meta,
preliminary_view,
prior_period_end,
)
from openbb_sec.utils.statement_schema._extraction import (
_get_annual_values,
_get_unit_data,
_get_ytd9_values,
compute_ref_filings,
extract_row_values,
rescaled_facts,
)
from openbb_sec.utils.statement_schema._types import (
SUPERSEDED_SUFFIX,
RowDef,
RowResult,
ScaledFacts,
_tolerance,
)
_FIXTURE_DIR = Path(__file__).parent / "record"
_M = 1_000_000 # magnitudes must exceed the 1M tolerance cap to register
# ---------------------------------------------------------------------------
# Builders
# ---------------------------------------------------------------------------
def _entry(
end, start=None, *, form="10-K", filed=None, fy=None, fp="FY", val=1.0, accn=None
):
"""Build a single XBRL fact entry dict; filed defaults to 45 days after end."""
if filed is None:
try:
filed = (datetime.strptime(end, "%Y-%m-%d") + timedelta(days=45)).strftime(
"%Y-%m-%d"
)
except ValueError:
filed = "2024-02-15"
e = {"end": end, "val": val, "form": form, "filed": filed, "fp": fp}
if accn is not None:
e["accn"] = accn
if start is not None:
e["start"] = start
if fy is not None:
e["fy"] = fy
elif end[:4].isdigit():
e["fy"] = int(end[:4])
else:
e["fy"] = 0
return e
_SHARES = "WeightedAverageNumberOfSharesOutstandingBasic"
def _cover(*counts):
"""Build dei cover-page share counts from (accession, filed, count) tuples."""
return (
"dei",
"EntityCommonStockSharesOutstanding",
"shares",
[_entry(filed, filed=filed, val=val, accn=accn) for accn, filed, val in counts],
)
def _facts(tags):
"""Build a facts dict: {ns: {tag: {units: {unit: [entries]}}}}.
``tags`` is an iterable of (ns, tag, unit, [entries]) tuples.
"""
out: dict = {}
for ns, tag, unit, entries in tags:
out.setdefault(ns, {}).setdefault(tag, {"units": {}})
out[ns][tag]["units"].setdefault(unit, []).extend(entries)
return out
def _rd(
tag,
*,
xbrl=(),
unit="monetary",
period_type="duration",
parent=None,
factor="+",
balance="",
sequence=1,
label=None,
):
"""Build a RowDef with a tuple of {tag, namespace} xbrl entries."""
return RowDef(
tag=tag,
label=label or tag.replace("_", " ").title(),
description="",
parent=parent,
sequence=sequence,
factor=factor,
balance=balance,
unit=unit,
period_type=period_type,
xbrl_tags=tuple({"tag": t, "namespace": ns} for t, ns in xbrl),
)
# ---------------------------------------------------------------------------
# _types._tolerance
# ---------------------------------------------------------------------------
class TestTolerance:
def test_floor(self):
# Small magnitudes floor at 100k.
assert _tolerance(1.0, 2.0) == 100_000
def test_cap(self):
# 0.1% of 10B = 10M, capped at 1M.
assert _tolerance(10_000_000_000.0) == 1_000_000
def test_scaled_between(self):
# 0.1% of 500M = 500k, between floor and cap.
assert _tolerance(500_000_000.0) == 500_000
def test_ignores_none(self):
assert _tolerance(None, None) == 100_000
# ---------------------------------------------------------------------------
# _detection
# ---------------------------------------------------------------------------
_DETECT_KW: dict[str, Any] = dict(
insurance_is_signals=["PremiumsEarnedNet", "BenefitsLossesAndExpenses"],
insurance_bs_signals=["LiabilityForFuturePolicyBenefits", "UnearnedPremiums"],
financial_signals=["InterestIncomeExpenseNet", "NoninterestIncome"],
min_financial_signals=2,
industrial_signals=["CostOfGoodsAndServicesSold", "CostOfRevenue"],
diversified_signals=["CostsAndExpenses", "OperatingIncomeLoss"],
)
class TestDetectType:
def test_insurance_wins_when_total_exceeds_financial(self):
facts = _facts(
[
(
"us-gaap",
"PremiumsEarnedNet",
"USD",
[_entry("2023-12-31", "2023-01-01")],
),
(
"us-gaap",
"LiabilityForFuturePolicyBenefits",
"USD",
[_entry("2023-12-31")],
),
("us-gaap", "UnearnedPremiums", "USD", [_entry("2023-12-31")]),
(
"us-gaap",
"InterestIncomeExpenseNet",
"USD",
[_entry("2023-12-31", "2023-01-01")],
),
(
"us-gaap",
"NoninterestIncome",
"USD",
[_entry("2023-12-31", "2023-01-01")],
),
]
)
# ins_total = 3 (1 IS + 2 BS), fin_count = 2 -> insurance
assert detect_type(facts, **_DETECT_KW) == "insurance"
def test_financial_wins_when_count_exceeds_insurance_total(self):
facts = _facts(
[
(
"us-gaap",
"PremiumsEarnedNet",
"USD",
[_entry("2023-12-31", "2023-01-01")],
),
(
"us-gaap",
"LiabilityForFuturePolicyBenefits",
"USD",
[_entry("2023-12-31")],
),
(
"us-gaap",
"InterestIncomeExpenseNet",
"USD",
[_entry("2023-12-31", "2023-01-01")],
),
(
"us-gaap",
"NoninterestIncome",
"USD",
[_entry("2023-12-31", "2023-01-01")],
),
]
)
# ins_total = 2, fin_count = 2 -> not > so financial branch:
# is_insurance and is_financial, ins_total(2) > fin_count(2) is False -> financial
assert detect_type(facts, **_DETECT_KW) == "financial"
def test_insurance_only(self):
facts = _facts(
[
(
"us-gaap",
"PremiumsEarnedNet",
"USD",
[_entry("2023-12-31", "2023-01-01")],
),
(
"us-gaap",
"LiabilityForFuturePolicyBenefits",
"USD",
[_entry("2023-12-31")],
),
]
)
assert detect_type(facts, **_DETECT_KW) == "insurance"
def test_financial_only(self):
facts = _facts(
[
(
"us-gaap",
"InterestIncomeExpenseNet",
"USD",
[_entry("2023-12-31", "2023-01-01")],
),
(
"us-gaap",
"NoninterestIncome",
"USD",
[_entry("2023-12-31", "2023-01-01")],
),
]
)
assert detect_type(facts, **_DETECT_KW) == "financial"
def test_industrial_requires_recent_data(self):
# COGS present and recent (within 5 years of an annual filing) -> industrial.
facts = _facts(
[
(
"us-gaap",
"CostOfGoodsAndServicesSold",
"USD",
[_entry("2024-12-31", "2024-01-01", form="10-K")],
),
]
)
assert detect_type(facts, **_DETECT_KW) == "industrial"
def test_stale_cogs_falls_through_to_diversified(self):
# COGS only present in an ancient filing -> not "recent" -> diversified via CostsAndExpenses.
facts = _facts(
[
(
"us-gaap",
"CostOfGoodsAndServicesSold",
"USD",
[_entry("2005-12-31", "2005-01-01", form="10-K")],
),
(
"us-gaap",
"CostsAndExpenses",
"USD",
[_entry("2024-12-31", "2024-01-01")],
),
]
)
assert detect_type(facts, **_DETECT_KW) == "diversified"
def test_default_industrial_when_no_signals(self):
facts = _facts([("us-gaap", "SomeRandomTag", "USD", [_entry("2023-12-31")])])
assert detect_type(facts, **_DETECT_KW) == "industrial"
def test_non_dict_namespace_skipped(self):
# detect_type iterates facts.values(); non-dict values are ignored.
facts = {
"weird": ["not", "a", "dict"],
"us-gaap": {"CostsAndExpenses": {"units": {}}},
}
assert detect_type(facts, **_DETECT_KW) == "diversified"
class TestLatestAnnualShares:
def test_null_revenue_value_skipped(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry("2023-12-31", "2023-01-01", val=None, accn="a1"),
_entry("2023-12-31", "2023-01-01", val=200.0, accn="a1"),
],
),
(
"us-gaap",
"PremiumsEarnedNet",
"USD",
[_entry("2023-12-31", "2023-01-01", val=50.0, accn="a1")],
),
]
)
revenue, shares = _latest_annual_shares(
facts, ["Revenues"], {"insurance": ["PremiumsEarnedNet"]}
)
assert revenue == 200.0
assert shares == {"insurance": 0.25}
def test_zero_revenue_returns_no_shares(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", val=0.0, accn="a1")],
)
]
)
assert _latest_annual_shares(
facts, ["Revenues"], {"insurance": ["PremiumsEarnedNet"]}
) == (None, {"insurance": None})
def test_non_dict_namespace_skipped_in_measure_lookup(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", val=100.0)],
),
(
"us-gaap",
"InterestIncomeOperating",
"USD",
[_entry("2023-12-31", "2023-01-01", val=40.0)],
),
]
)
facts["weird"] = ["not", "a", "dict"]
revenue, shares = _latest_annual_shares(
facts, ["Revenues"], {"financial": ["InterestIncomeOperating"]}
)
assert revenue == 100.0
assert shares == {"financial": 0.4}
class TestCollapseNearDates:
def test_interim_near_fiscal_year_end_dropped(self):
kept = _collapse_near_dates(
{"2023-03-31", "2023-12-28"}, {"2023-12-31"}, Counter()
)
assert kept == {"2023-03-31"}
def test_better_supported_interim_kept(self):
weights = Counter({"2023-06-30": 1, "2023-07-02": 3})
assert _collapse_near_dates({"2023-06-30", "2023-07-02"}, set(), weights) == {
"2023-07-02"
}
def test_first_interim_kept_when_not_outweighed(self):
weights = Counter({"2023-06-30": 3, "2023-07-02": 1})
assert _collapse_near_dates({"2023-06-30", "2023-07-02"}, set(), weights) == {
"2023-06-30"
}
class TestMalformedDates:
def test_filing_dates_skip_unparseable_end(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-02-30", "2022-03-01", filed="2023-05-01")],
)
]
)
assert get_filing_dates(facts, "annual") == set()
def test_annual_fiscal_years_skip_unparseable_end(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-02-30", "2022-03-01", filed="2023-05-01")],
)
]
)
assert annual_fiscal_years(facts, set()) == {}
class TestFiscalPosition:
_ENDS = ["2022-12-31", "2023-12-31"]
_YEARS = {"2022-12-31": 2022, "2023-12-31": 2023}
def test_date_before_grid_rolls_back(self):
assert _fiscal_position("2020-06-30", self._ENDS, self._YEARS) == (2020, 2)
def test_date_after_grid_rolls_forward(self):
assert _fiscal_position("2025-06-30", self._ENDS, self._YEARS) == (2025, 2)
class TestPreliminaryView:
def _facts(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry("2023-12-31", "2023-01-01", filed="2024-02-15"),
_entry(
"2023-12-31", "2023-01-01", form="8-K", filed="2024-01-25"
),
_entry(
"2024-12-31", "2024-01-01", form="8-K", filed="2025-01-25"
),
],
),
(
"us-gaap",
"Assets",
"USD",
[_entry("2023-12-31", filed="2024-02-15")],
),
(
"dei",
"EntityCommonStockSharesOutstanding",
"shares",
[_entry("2024-01-31", form="10-K", filed="2024-02-15")],
),
]
)
facts["weird"] = ["not", "a", "dict"]
return facts
def test_reported_period_superseded_and_unreported_kept(self):
facts = self._facts()
view = preliminary_view(facts)
forms = [e["form"] for e in view["us-gaap"]["Revenues"]["units"]["USD"]]
assert forms == ["10-K", "8-K" + SUPERSEDED_SUFFIX, "8-K"]
assert view["us-gaap"]["Assets"] is facts["us-gaap"]["Assets"]
assert view["weird"] is facts["weird"]
assert view["dei"] == facts["dei"]
def test_pit_mode_keeps_entry_filed_before_regular_filing(self):
view = preliminary_view(self._facts(), pit_mode=True)
forms = [e["form"] for e in view["us-gaap"]["Revenues"]["units"]["USD"]]
assert forms == ["10-K", "8-K", "8-K"]
class TestHasRecentData:
def test_true_for_recent_annual(self):
facts = _facts(
[
(
"us-gaap",
"CostOfRevenue",
"USD",
[_entry("2024-12-31", "2024-01-01", form="10-K")],
)
]
)
assert _has_recent_data(facts, "CostOfRevenue") is True
def test_false_for_old(self):
facts = _facts(
[
(
"us-gaap",
"CostOfRevenue",
"USD",
[_entry("2005-12-31", "2005-01-01", form="10-K")],
)
]
)
assert _has_recent_data(facts, "CostOfRevenue") is False
def test_false_for_non_annual_form(self):
facts = _facts(
[
(
"us-gaap",
"CostOfRevenue",
"USD",
[_entry("2024-09-30", "2024-07-01", form="10-Q")],
)
]
)
assert _has_recent_data(facts, "CostOfRevenue") is False
def test_missing_tag(self):
facts = _facts([("us-gaap", "Other", "USD", [_entry("2024-12-31")])])
assert _has_recent_data(facts, "CostOfRevenue") is False
class TestGetFilingDates:
def test_annual_basic(self):
facts = _facts(
[
(
"us-gaap",
"Assets",
"USD",
[_entry(f"{y}-12-31") for y in (2022, 2023)],
),
(
"us-gaap",
"Revenues",
"USD",
[_entry(f"{y}-12-31", f"{y}-01-01") for y in (2022, 2023)],
),
]
)
assert get_filing_dates(facts, "annual") == {"2022-12-31", "2023-12-31"}
def test_bad_dates_skipped(self):
# start==end and unparseable dates are ignored, leaving no annual dates.
facts = _facts(
[
("us-gaap", "Revenues", "USD", [_entry("2023-12-31", "2023-12-31")]),
("us-gaap", "Other", "USD", [_entry("bad", "worse")]),
]
)
assert get_filing_dates(facts, "annual") == set()
def test_quarterly_merges_annual_and_drops_orphan_quarter(self):
# A quarter end with no instant Assets anchor and earlier than others gets dropped.
facts = _facts(
[
(
"us-gaap",
"Assets",
"USD",
[
_entry("2023-03-31", form="10-Q"),
_entry("2023-06-30", form="10-Q"),
_entry("2023-12-31", form="10-K"),
],
),
(
"us-gaap",
"Revenues",
"USD",
[
_entry("2023-03-31", "2023-01-01", form="10-Q"),
_entry("2023-06-30", "2023-04-01", form="10-Q"),
_entry("2023-12-31", "2023-01-01", form="10-K"),
],
),
]
)
dates = get_filing_dates(facts, "quarterly")
assert "2023-03-31" in dates
assert "2023-12-31" in dates # annual merged in
def test_canonical_month_filter_keeps_offcycle_without_neighbor(self):
# >3 annual dates, dominant 12-31, plus an isolated 06-30 far from any canonical.
rev = [
_entry(f"{y}-12-31", f"{y}-01-01") for y in (2020, 2021, 2022, 2023, 2024)
]
assets = [_entry(f"{y}-12-31") for y in (2020, 2021, 2022, 2023, 2024)]
rev.append(_entry("2016-06-30", "2015-07-01"))
assets.append(_entry("2016-06-30"))
facts = _facts(
[
("us-gaap", "Assets", "USD", assets),
("us-gaap", "Revenues", "USD", rev),
]
)
dates = get_filing_dates(facts, "annual")
assert "2016-06-30" in dates
assert "2024-12-31" in dates
def test_canonical_month_filter_drops_near_duplicate(self):
# Two canonical dates within 10 days: the non-exact one is discarded.
rev = [_entry(f"{y}-12-31", f"{y}-01-01") for y in (2021, 2022, 2023, 2024)]
assets = [_entry(f"{y}-12-31") for y in (2021, 2022, 2023, 2024)]
# Near-duplicate 52-week fiscal end a few days before a calendar 12-31.
rev.append(_entry("2024-12-28", "2024-01-01"))
assets.append(_entry("2024-12-28"))
facts = _facts(
[
("us-gaap", "Assets", "USD", assets),
("us-gaap", "Revenues", "USD", rev),
]
)
dates = get_filing_dates(facts, "annual")
# The exact 12-31 wins over the 12-28 near-duplicate.
assert "2024-12-31" in dates
assert "2024-12-28" not in dates
def test_include_preliminary_adds_8k(self):
facts = _facts(
[
("us-gaap", "Assets", "USD", [_entry("2024-12-31")]),
("us-gaap", "Revenues", "USD", [_entry("2024-12-31", "2024-01-01")]),
(
"us-gaap",
"Revenues",
"USD",
[_entry("2025-12-31", "2025-01-01", form="8-K")],
),
]
)
with_prelim = get_filing_dates(facts, "annual", include_preliminary=True)
assert "2025-12-31" in with_prelim
without = get_filing_dates(facts, "annual")
assert "2025-12-31" not in without
class TestGetFiscalMeta:
def test_annual_descending_year_correction(self):
# Two annual dates where the earlier filing erroneously claims a >= fy;
# get_fiscal_meta forces the earlier to be next_fy - 1.
facts = _facts(
[
(
"us-gaap",
"Assets",
"USD",
[_entry("2022-12-31"), _entry("2023-12-31")],
),
(
"us-gaap",
"Revenues",
"USD",
[
_entry("2022-12-31", "2022-01-01", fy=2023, fp="FY"),
_entry("2023-12-31", "2023-01-01", fy=2023, fp="FY"),
],
),
]
)
dates = get_filing_dates(facts, "annual")
meta = get_fiscal_meta(facts, "annual", dates)
assert meta["2023-12-31"]["fiscal_year"] == 2023
assert meta["2022-12-31"]["fiscal_year"] == 2022 # corrected down
def test_annual_fallback_year_from_date(self):
# No fy/fp metadata at all -> falls back to int(date[:4]).
facts = _facts(
[
("us-gaap", "Assets", "USD", [_entry("2023-12-31", fy=None, fp="")]),
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", fp="")],
),
]
)
dates = {"2023-12-31"}
meta = get_fiscal_meta(facts, "annual", dates)
assert meta["2023-12-31"] == {"fiscal_year": 2023, "fiscal_period": "FY"}
def test_quarterly_period_from_10q(self):
facts = _facts(
[
(
"us-gaap",
"Assets",
"USD",
[
_entry("2023-09-30", form="10-Q", fy=2023, fp="Q3"),
_entry("2023-12-31", form="10-K", fy=2023, fp="FY"),
],
),
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-09-30", "2023-07-01", form="10-Q", fy=2023, fp="Q3"
),
_entry(
"2023-12-31", "2023-01-01", form="10-K", fy=2023, fp="FY"
),
],
),
]
)
dates = get_filing_dates(facts, "quarterly")
meta = get_fiscal_meta(facts, "quarterly", dates)
assert meta["2023-09-30"]["fiscal_period"] == "Q3"
# Annual date present in quarterly extraction becomes Q4.
assert meta["2023-12-31"]["fiscal_period"] == "Q4"
class TestDetectCurrency:
def test_picks_most_common(self):
facts = _facts(
[
("us-gaap", "A", "EUR", [_entry("2023-12-31")]),
("us-gaap", "B", "EUR", [_entry("2023-12-31")]),
("us-gaap", "C", "USD", [_entry("2023-12-31")]),
]
)
assert detect_reporting_currency(facts) == "EUR"
def test_skips_shares_and_ratios(self):
facts = _facts(
[
("us-gaap", "Shares", "shares", [_entry("2023-12-31")]),
("us-gaap", "Ratio", "pure", [_entry("2023-12-31")]),
("us-gaap", "PerShare", "USD/shares", [_entry("2023-12-31")]),
]
)
# Only currency-looking 3-letter upper keys count; none here -> USD default.
assert detect_reporting_currency(facts) == "USD"
def test_empty_defaults_usd(self):
assert detect_reporting_currency({}) == "USD"
class TestPriorPeriodEnd:
@pytest.mark.parametrize(
"date,expected",
[
("2023-03-31", "2022-12-31"),
("2023-06-30", "2023-03-31"),
("2023-09-30", "2023-06-30"),
("2023-12-31", "2023-09-30"),
("2023-01-31", "2022-10-31"),
("2023-04-30", "2023-01-31"),
],
)
def test_known_months(self, date, expected):
assert prior_period_end(date) == expected
def test_unmapped_month_returns_none(self):
assert prior_period_end("2023-05-31") is None
def test_bad_date_returns_none(self):
assert prior_period_end("garbage") is None
# ---------------------------------------------------------------------------
# _extraction
# ---------------------------------------------------------------------------
class TestGetUnitData:
def test_shares_key(self):
td = {"units": {"shares": [{"val": 1}], "USD": [{"val": 2}]}}
assert _get_unit_data(td, "shares") == [{"val": 1}]
def test_per_share_exact(self):
td = {"units": {"USD/shares": [{"val": 1.5}]}}
assert _get_unit_data(td, "per_share", "USD") == [{"val": 1.5}]
def test_per_share_prefix_fallback(self):
td = {"units": {"USD/foo": [{"val": 9}]}}
assert _get_unit_data(td, "per_share", "USD") == [{"val": 9}]
def test_monetary_currency_match(self):
td = {"units": {"EUR": [{"val": 3}]}}
assert _get_unit_data(td, "monetary", "EUR") == [{"val": 3}]
def test_falls_back_to_first_unit(self):
td = {"units": {"GBP": [{"val": 7}]}}
# Requesting USD monetary but only GBP present -> first unit returned.
assert _get_unit_data(td, "monetary", "USD") == [{"val": 7}]
def test_no_units_returns_none(self):
assert _get_unit_data({"units": {}}, "monetary") is None
class TestGetAnnualValues:
def test_instant_returns_empty(self):
row = _rd("x", period_type="instant", xbrl=[("Assets", "us-gaap")])
assert _get_annual_values({}, row) == {}
def test_picks_earliest_filed(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-12-31", "2023-01-01", filed="2024-02-01", val=500.0
),
_entry(
"2023-12-31", "2023-01-01", filed="2025-02-01", val=510.0
),
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
res = _get_annual_values(facts, row)
assert res["2023-12-31"][1] == 500.0 # earliest filing wins
assert res["2023-12-31"][2] == "us-gaap:Revenues"
def test_tag_chain_priority(self):
facts = _facts(
[
(
"us-gaap",
"RevA",
"USD",
[_entry("2023-12-31", "2023-01-01", val=100.0)],
),
(
"us-gaap",
"RevB",
"USD",
[_entry("2023-12-31", "2023-01-01", val=200.0)],
),
]
)
row = _rd("total_revenue", xbrl=[("RevA", "us-gaap"), ("RevB", "us-gaap")])
res = _get_annual_values(facts, row)
assert res["2023-12-31"][1] == 100.0 # first tag in chain
def test_duration_window_filter(self):
# A 200-day duration is not annual (300-400) -> excluded.
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-07-01", "2022-12-01", val=9.0)],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert _get_annual_values(facts, row) == {}
def test_reference_map_selects_mapped_filing(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-12-31", "2023-01-01", filed="2024-02-01", val=500.0
),
_entry(
"2023-12-31", "2023-01-01", filed="2025-02-01", val=510.0
),
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
res = _get_annual_values(facts, row, ref_filed_map={"2023-12-31": "2025-02-01"})
assert res["2023-12-31"] == ("2023-01-01", 510.0, "us-gaap:Revenues")
def test_reference_map_falls_back_to_latest_earlier_filing(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-12-31", "2023-01-01", filed="2024-02-01", val=500.0
),
_entry(
"2023-12-31", "2023-01-01", filed="2024-05-01", val=505.0
),
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
res = _get_annual_values(facts, row, ref_filed_map={"2023-12-31": "2025-02-01"})
assert res["2023-12-31"] == (
"2023-01-01",
505.0,
"us-gaap:Revenues(fallback)",
)
def test_reference_map_falls_back_to_earliest_later_filing(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-12-31", "2023-01-01", filed="2025-02-01", val=510.0
),
_entry(
"2023-12-31", "2023-01-01", filed="2025-05-01", val=515.0
),
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
res = _get_annual_values(facts, row, ref_filed_map={"2023-12-31": "2024-02-01"})
assert res["2023-12-31"][1:] == (510.0, "us-gaap:Revenues(fallback)")
def test_reference_map_skips_unmapped_period(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", val=500.0)],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert _get_annual_values(facts, row, ref_filed_map={}) == {}
class TestGetYtd9Values:
def test_instant_empty(self):
row = _rd("x", period_type="instant", xbrl=[("A", "us-gaap")])
assert _get_ytd9_values({}, row) == {}
def test_q4_from_fy_minus_ytd9(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-12-31", "2023-01-01", filed="2024-02-01", val=1000.0
),
_entry(
"2023-09-30", "2023-01-01", filed="2024-02-01", val=700.0
),
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
res = _get_ytd9_values(facts, row)
assert res["2023-12-31"] == 300.0 # 1000 - 700
def test_missing_ytd_returns_empty(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", val=1000.0)],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert _get_ytd9_values(facts, row) == {}
class TestExtractRowValues:
def test_annual_simple(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", val=500.0)],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, srcs = extract_row_values(facts, row, "annual", "USD")
assert vals["2023-12-31"] == 500.0
assert srcs["2023-12-31"] == "us-gaap:Revenues"
def test_instant_balance_sheet(self):
facts = _facts(
[("us-gaap", "Assets", "USD", [_entry("2023-12-31", val=999.0)])]
)
row = _rd("total_assets", period_type="instant", xbrl=[("Assets", "us-gaap")])
vals, _ = extract_row_values(facts, row, "annual", "USD")
assert vals["2023-12-31"] == 999.0
def test_cross_target_identity_lock(self):
facts = _facts(
[
(
"us-gaap",
"ProfitLoss",
"USD",
[_entry("2023-12-31", "2023-01-01", val=42.0)],
)
]
)
row = _rd("net_income", xbrl=[("ProfitLoss", "us-gaap")])
vals, srcs = extract_row_values(
facts,
row,
"annual",
"USD",
cross_targets={"2023-12-31": 42.0},
statement="cash_flow",
)
assert vals["2023-12-31"] == 42.0
assert "identity_lock:cash_flow" in srcs["2023-12-31"]
def test_identity_locked_quarter_keeps_its_concept_for_ytd(self):
facts = _facts(
[
(
"us-gaap",
"A",
"USD",
[
_entry("2023-03-31", "2023-01-01", form="10-Q", val=100.0),
_entry("2023-06-30", "2023-01-01", form="10-Q", val=200.0),
],
),
(
"us-gaap",
"B",
"USD",
[
_entry("2023-03-31", "2023-01-01", form="10-Q", val=205.0),
_entry("2023-06-30", "2023-04-01", form="10-Q", val=207.0),
],
),
]
)
row = _rd(
"depreciation_and_amortization",
xbrl=[("A", "us-gaap"), ("B", "us-gaap")],
)
vals, srcs = extract_row_values(
facts,
row,
"quarterly",
"USD",
cross_targets={"2023-03-31": 100.0},
statement="cash_flow",
)
assert vals["2023-06-30"] == 100.0
assert srcs["2023-06-30"] == "ytd_derived(us-gaap:A)"
def test_share_count_scale_restated_before_extraction(self):
facts = _facts(
[
(
"us-gaap",
_SHARES,
"shares",
[
_entry(
"2017-12-31",
"2017-01-01",
filed="2019-02-21",
val=351.9,
accn="x",
),
],
),
_cover(("x", "2019-02-21", 350_000_000), ("y", "2018-05-01", 352 * _M)),
]
)
row = _rd(
"weighted_ave_basic_shares_os", unit="shares", xbrl=[(_SHARES, "us-gaap")]
)
vals, srcs = extract_row_values(
rescaled_facts(facts),
row,
"annual",
"USD",
ref_filed_map={"2017-12-31": "2019-02-21"},
)
assert vals["2017-12-31"] == 351_900_000
assert isinstance(vals["2017-12-31"], int)
assert srcs["2017-12-31"] == f"us-gaap:{_SHARES}"
def test_ref_filed_fallback_when_exact_missing(self):
# ref_filed_map points to a date not in the filings -> fallback path.
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", filed="2024-02-01", val=500.0)],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, srcs = extract_row_values(
facts, row, "annual", "USD", ref_filed_map={"2023-12-31": "2099-01-01"}
)
assert vals["2023-12-31"] == 500.0
assert "(fallback)" in srcs["2023-12-31"]
def test_quarterly_q4_derivation_from_fy_minus_quarters(self):
# Three 10-Q quarters + FY -> Q4 = FY - sum(Q1..Q3).
entries = [
_entry("2023-03-31", "2023-01-01", form="10-Q", val=100.0),
_entry("2023-06-30", "2023-04-01", form="10-Q", val=150.0),
_entry("2023-09-30", "2023-07-01", form="10-Q", val=200.0),
_entry("2023-12-31", "2023-01-01", form="10-K", val=600.0),
]
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, srcs = extract_row_values(facts, row, "quarterly", "USD")
assert vals["2023-12-31"] == 150.0 # 600 - (100+150+200)
assert "Q4:" in srcs["2023-12-31"]
def test_quarterly_q4_uses_annual_reference_map(self):
entries = [
_entry("2023-03-31", "2023-01-01", form="10-Q", val=100.0),
_entry("2023-06-30", "2023-04-01", form="10-Q", val=150.0),
_entry("2023-09-30", "2023-07-01", form="10-Q", val=200.0),
_entry("2023-12-31", "2023-01-01", filed="2024-02-15", val=600.0),
_entry("2023-12-31", "2023-01-01", filed="2025-02-15", val=660.0),
]
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, srcs = extract_row_values(
facts,
row,
"quarterly",
"USD",
annual_ref_map={"2023-12-31": "2025-02-15"},
)
assert vals["2023-12-31"] == 210.0 # 660 - (100+150+200)
assert srcs["2023-12-31"].startswith("Q4: FY[us-gaap:Revenues]")
def test_quarterly_h2_derivation_single_interim(self):
# Semi-annual reporter: FY + one H1 interim -> H2 = FY - H1.
entries = [
_entry("2023-06-30", "2023-01-01", form="6-K", val=400.0),
_entry("2023-12-31", "2023-01-01", form="10-K", val=900.0),
]
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, srcs = extract_row_values(facts, row, "quarterly", "USD")
assert vals["2023-12-31"] == 500.0 # 900 - 400
assert "H2:" in srcs["2023-12-31"]
def test_shares_unit_has_no_q4_derivation(self):
entries = [_entry("2023-12-31", "2023-01-01", form="10-K", val=1_000.0)]
facts = _facts([("us-gaap", "WAS", "shares", entries)])
row = _rd(
"weighted_average_shares_outstanding",
unit="shares",
xbrl=[("WAS", "us-gaap")],
)
vals, srcs = extract_row_values(facts, row, "quarterly", "USD")
assert "2023-12-31" not in vals
assert "2023-12-31" not in srcs
def test_missing_tag_returns_empty(self):
row = _rd("total_revenue", xbrl=[("Nope", "us-gaap")])
vals, srcs = extract_row_values({}, row, "annual", "USD")
assert vals == {} and srcs == {}
def test_quarterly_ignores_annual_form_interim_monetary_facts(self):
entries = [
_entry("2023-03-31", "2023-01-01", form="10-K", val=100.0),
_entry("2023-12-31", "2023-01-01", form="10-K", val=600.0),
]
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, _ = extract_row_values(facts, row, "quarterly", "USD")
assert "2023-03-31" not in vals
def test_quarterly_keeps_annual_form_interim_share_facts(self):
entries = [_entry("2023-12-31", "2023-10-01", form="10-K", val=7.0)]
facts = _facts([("us-gaap", "WAS", "shares", entries)])
row = _rd("shares", unit="shares", xbrl=[("WAS", "us-gaap")])
vals, _ = extract_row_values(facts, row, "quarterly", "USD")
assert vals["2023-12-31"] == 7.0
def test_quarterly_q4_from_quarters_concept(self):
quarters = [
_entry("2023-03-31", "2023-01-01", form="10-Q", val=200.0),
_entry("2023-06-30", "2023-04-01", form="10-Q", val=250.0),
_entry("2023-09-30", "2023-07-01", form="10-Q", val=300.0),
_entry("2023-12-31", "2023-01-01", form="10-K", val=1000.0),
]
subset = [_entry("2023-12-31", "2023-01-01", form="10-K", val=700.0)]
facts = _facts(
[
("us-gaap", "Revenues", "USD", quarters),
(
"us-gaap",
"RevenueFromContractWithCustomerExcludingAssessedTax",
"USD",
subset,
),
]
)
row = _rd(
"operating_revenue",
xbrl=[
("RevenueFromContractWithCustomerExcludingAssessedTax", "us-gaap"),
("Revenues", "us-gaap"),
],
)
vals, srcs = extract_row_values(facts, row, "quarterly", "USD")
assert vals["2023-12-31"] == 250.0
assert srcs["2023-12-31"].startswith("Q4: FY[us-gaap:Revenues]")
def test_quarterly_q4_from_annual_concept_without_quarters_concept(self):
entries = [
_entry("2023-03-31", "2023-01-01", form="10-Q", val=200.0),
_entry("2023-06-30", "2023-04-01", form="10-Q", val=250.0),
_entry("2023-09-30", "2023-07-01", form="10-Q", val=300.0),
]
subset = [_entry("2023-12-31", "2023-01-01", form="10-K", val=700.0)]
facts = _facts(
[
("us-gaap", "Revenues", "USD", entries),
(
"us-gaap",
"RevenueFromContractWithCustomerExcludingAssessedTax",
"USD",
subset,
),
]
)
row = _rd(
"operating_revenue",
xbrl=[
("RevenueFromContractWithCustomerExcludingAssessedTax", "us-gaap"),
("Revenues", "us-gaap"),
],
)
vals, srcs = extract_row_values(facts, row, "quarterly", "USD")
assert vals["2023-12-31"] == -50.0
assert srcs["2023-12-31"].startswith(
"Q4: FY[us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax]"
)
def test_instant_row_skips_duration_facts(self):
facts = _facts(
[
(
"us-gaap",
"Assets",
"USD",
[_entry("2023-12-31", "2023-01-01", val=5.0)],
)
]
)
row = _rd("total_assets", period_type="instant", xbrl=[("Assets", "us-gaap")])
assert extract_row_values(facts, row, "annual", "USD") == ({}, {})
def test_negative_net_position_skipped_in_asset_row(self):
net = "DeferredTaxAssetsLiabilitiesNetNoncurrent"
facts = _facts(
[
(
"us-gaap",
net,
"USD",
[_entry("2022-12-31", val=40.0), _entry("2023-12-31", val=-50.0)],
)
]
)
row = _rd(
"noncurrent_deferred_tax_assets",
period_type="instant",
balance="debit",
xbrl=[(net, "us-gaap")],
)
vals, _ = extract_row_values(facts, row, "annual", "USD")
assert vals == {"2022-12-31": 40.0}
class TestRescaledFacts:
def test_filing_scaled_in_full(self):
entries = [
_entry(f"{y}-12-31", f"{y}-01-01", filed=filed, val=val, accn=accn)
for y in range(2001, 2011)
for accn, filed, val in (
("bad", "2012-02-01", y),
("b", "2012-03-01", y * _M),
("c", "2013-03-01", y * _M),
)
]
entries.append(
_entry("2011-12-31", "2011-01-01", filed="2012-02-01", val=2.5, accn="bad")
)
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
out = rescaled_facts(facts)["us-gaap"]["Revenues"]["units"]["USD"]
vals = {(e["end"], e["accn"]): e["val"] for e in out}
assert vals[("2001-12-31", "bad")] == 2001 * _M
assert vals[("2011-12-31", "bad")] == 2_500_000
assert isinstance(vals[("2011-12-31", "bad")], int)
assert vals[("2001-12-31", "b")] == 2001 * _M
assert facts["us-gaap"]["Revenues"]["units"]["USD"][0]["val"] == 2001
@pytest.mark.parametrize(
("val", "covers", "expected"),
[
(
351.9,
(("x", "2019-02-21", 350 * _M), ("y", "2018-05-01", 352 * _M)),
351_900_000,
),
(
116_238_493,
(("x", "2019-02-21", 116_238), ("y", "2018-05-01", 116_000)),
116_238.493,
),
(
351.9,
(("x", "2019-02-21", 350_000 * _M), ("y", "2018-05-01", 352 * _M)),
351.9,
),
(351.9, (("x", "2019-02-21", 350 * _M),), 351.9),
],
)
def test_share_count_restated_to_cover_page(self, val, covers, expected):
facts = _facts(
[
(
"us-gaap",
_SHARES,
"shares",
[
_entry(
"2017-12-31",
"2017-01-01",
filed="2019-02-21",
val=val,
accn="x",
)
],
),
_cover(*covers),
]
)
value = rescaled_facts(facts)["us-gaap"][_SHARES]["units"]["shares"][0]["val"]
assert value == expected
assert type(value) is type(expected)
def test_disputed_value_takes_concept_scale(self):
entries = [
_entry("2020-12-31", "2020-01-01", filed="2021-02-23", val=-26.3, accn="a"),
_entry(
"2020-12-31", "2020-01-01", filed="2023-02-24", val=26_300_000, accn="b"
),
_entry(
"2023-12-31", "2023-01-01", filed="2024-02-22", val=71_700_000, accn="c"
),
_entry(
"2020-06-30",
"2020-04-01",
form="10-Q",
filed="2020-08-10",
val=11_900_000,
accn="d",
),
]
facts = _facts([("us-gaap", "AssetImpairmentCharges", "USD", entries)])
out = rescaled_facts(facts)["us-gaap"]["AssetImpairmentCharges"]["units"]["USD"]
assert [e["val"] for e in out] == [
-26_300_000,
26_300_000,
71_700_000,
11_900_000,
]
def test_disputed_value_consistent_with_own_filing_kept(self):
def quarter(year, filed, val, accn):
return _entry(
f"{year}-10-31",
f"{year}-08-01",
form="10-Q",
filed=filed,
val=val,
accn=accn,
)
entries = [
quarter(2023, "2023-12-07", 241_490_000, "a"),
quarter(2022, "2023-12-07", 228_544_000, "a"),
quarter(2023, "2024-12-05", 241_490, "b"),
*(quarter(y, f"{y}-12-05", 266_556, f"n{y}") for y in (2024, 2025, 2026)),
]
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
out = rescaled_facts(facts)["us-gaap"]["Revenues"]["units"]["USD"]
assert [e["val"] for e in out[:3]] == [241_490_000, 228_544_000, 241_490]
@pytest.mark.parametrize(
("entries", "expected"),
[
(
[
_entry("2020-12-31", "2020-01-01", filed="2021-02-01", val=5),
_entry("2020-12-31", "2020-01-01", filed="2022-02-01", val=5 * _M),
_entry("2019-12-31", "2019-01-01", filed="2020-02-01", val=150_000),
],
[5, 5 * _M, 150_000],
),
(
[
_entry("2019-12-31", "2019-10-01", filed="2020-01-31", val=395),
_entry(
"2019-12-31", "2019-10-01", filed="2021-02-01", val=395 * _M
),
_entry("2018-12-31", "2018-10-01", form="8-K", val=100),
],
[395, 395 * _M, 100],
),
],
)
def test_dispute_without_fitting_scale_unchanged(self, entries, expected):
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
out = rescaled_facts(facts)
assert isinstance(out, ScaledFacts)
assert out["us-gaap"] is facts["us-gaap"]
assert [e["val"] for e in out["us-gaap"]["Revenues"]["units"]["USD"]] == (
expected
)
def test_schema_restates_facts_once(self):
facts = _facts(
[("us-gaap", "Revenues", "USD", [_entry("2023-12-31", "2023-01-01")])]
)
scaled = StatementSchema._preliminary_facts(facts, False, False)
assert isinstance(scaled, ScaledFacts)
assert StatementSchema._preliminary_facts(scaled, False, False) is scaled
class TestComputeRefFilings:
def test_quarterly_ignores_annual_form_interim_monetary_facts(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-03-31", "2023-01-01", form="10-Q", filed="2023-05-01"
),
_entry(
"2023-03-31", "2023-01-01", form="10-K", filed="2024-02-01"
),
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
ref = compute_ref_filings(facts, [row], "quarterly", "USD")
assert ref == {"2023-03-31": "2023-05-01"}
def test_instant_row_ignores_duration_facts(self):
facts = _facts(
[
(
"us-gaap",
"Assets",
"USD",
[
_entry("2023-12-31", filed="2024-02-01"),
_entry("2023-12-31", "2023-01-01", filed="2024-06-01"),
],
)
]
)
row = _rd("total_assets", period_type="instant", xbrl=[("Assets", "us-gaap")])
ref = compute_ref_filings(facts, [row], "annual", "USD")
assert ref == {"2023-12-31": "2024-02-01"}
def test_prefers_latest_filed_normal_mode(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry("2023-12-31", "2023-01-01", filed="2024-02-01", val=1.0),
_entry("2023-12-31", "2023-01-01", filed="2024-05-01", val=1.0),
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
ref = compute_ref_filings(facts, [row], "annual", "USD")
assert ref["2023-12-31"] == "2024-05-01" # latest
def test_pit_mode_prefers_earliest(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry("2023-12-31", "2023-01-01", filed="2024-02-01", val=1.0),
_entry("2023-12-31", "2023-01-01", filed="2024-05-01", val=1.0),
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
ref = compute_ref_filings(facts, [row], "annual", "USD", pit_mode=True)
assert ref["2023-12-31"] == "2024-02-01" # earliest
def test_stale_filing_gap_excluded(self):
# filed > 450 days after period end is dropped in non-pit mode.
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", filed="2026-01-01", val=1.0)],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert compute_ref_filings(facts, [row], "annual", "USD") == {}
# ---------------------------------------------------------------------------
# End-to-end on the real BlackRock fixture (annual, quarterly, growth).
# These exercise the combinatorial extraction/imputation branches that
# only realistic XBRL data reaches.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def blk_facts():
with open(_FIXTURE_DIR / "CIK0002012383.json") as f:
return json.load(f)
class TestBLKEndToEnd:
def test_annual_pipeline(self, blk_facts):
res = resolve_company_facts(blk_facts, period="annual")
assert res.company_type == "diversified"
assert res.currency == "USD"
assert len(res.diagnostics) == 0
# Core identity holds on the latest year.
ta = next(
r["value"]
for r in res.balance_sheet
if r["tag"] == "total_assets" and r["period_ending"] == "2025-12-31"
)
tle = next(
r["value"]
for r in res.balance_sheet
if r["tag"] == "total_liabilities_and_equity"
and r["period_ending"] == "2025-12-31"
)
assert ta == tle
def test_quarterly_pipeline_runs_and_aligns(self, blk_facts):
res = resolve_company_facts(blk_facts, period="quarterly")
# Quarterly extraction exercises YTD/Q4 derivation at the shared reference vintage.
assert res.income_statement
# All three statements share the same set of period-ending dates.
is_dates = {r["period_ending"] for r in res.income_statement}
bs_dates = {r["period_ending"] for r in res.balance_sheet}
cf_dates = {r["period_ending"] for r in res.cash_flow}
assert is_dates == bs_dates == cf_dates
def test_pit_mode_quarterly(self, blk_facts):
# pit_mode resolves every period at its earliest filing.
res = resolve_company_facts(blk_facts, period="quarterly", pit_mode=True)
assert res.income_statement
def test_quarterly_skip_imputation_has_no_diagnostics(self, blk_facts):
results = StatementSchema().extract_all(
blk_facts, frequency="quarterly", skip_imputation=True
)
assert all(not r.diagnostics for r in results.values())
def test_both_periods(self, blk_facts):
res = resolve_company_facts(blk_facts, period="both")
freqs = {r["frequency"] for r in res.income_statement}
assert "annual" in freqs and "quarterly" in freqs
def test_yoy_growth(self, blk_facts):
res = resolve_company_facts(blk_facts, period="yoy")
assert res.income_statement
assert all(r["unit"] == "percent" for r in res.income_statement)
def test_ttm(self, blk_facts):
res = resolve_company_facts(blk_facts, period="ttm")
assert all(r["fiscal_period"] == "TTM" for r in res.income_statement)
def test_fiscal_year_filter(self, blk_facts):
res = resolve_company_facts(blk_facts, period="annual", fiscal_years=[2025])
years = {r["fiscal_year"] for r in res.income_statement}
assert years == {2025}
class TestSchemaObject:
"""Direct StatementSchema accessor coverage."""
@staticmethod
@pytest.fixture(scope="class")
def schema():
return StatementSchema()
def test_version_and_generated(self, schema):
assert schema.version == "2.0"
assert schema.generated == "2026-03-17"
def test_get_row_and_chain(self, schema):
row = schema.get_row("total_assets", "balance_sheet", "industrial")
assert row is not None
assert row.tag == "total_assets"
chain = schema.get_tag_chain("total_assets", "balance_sheet", "industrial")
assert isinstance(chain, tuple) and len(chain) > 0
@staticmethod
def _names(schema, tag, statement, company_type):
return [
f"{x['namespace']}:{x['tag']}"
for x in schema.get_tag_chain(tag, statement, company_type)
]
@pytest.mark.parametrize(
"company_type", ["industrial", "financial", "diversified", "insurance"]
)
def test_chain_scope(self, schema, company_type):
revenue = self._names(schema, "total_revenue", "income_statement", company_type)
assert revenue.index("us-gaap:Revenues") < revenue.index(
"us-gaap:SalesRevenueNet"
)
for statement in ("income_statement", "cash_flow"):
assert "us-gaap:OtherDepreciationAndAmortization" not in self._names(
schema, "depreciation_and_amortization", statement, company_type
)
sbc = self._names(schema, "stock_based_compensation", "cash_flow", company_type)
assert "us-gaap:StockIssuedDuringPeriodValueShareBasedCompensation" not in sbc
assert self._names(
schema, "temporary_equity", "balance_sheet", company_type
) == [
"us-gaap:TemporaryEquityCarryingAmountIncludingPortionAttributableToNoncontrollingInterests",
"us-gaap:TemporaryEquityCarryingAmount",
]
rnci = self._names(
schema, "redeemable_noncontrolling_interest", "balance_sheet", company_type
)
assert rnci[-1] == (
"us-gaap:TemporaryEquityCarryingAmountAttributableToNoncontrollingInterest"
)
interest = self._names(
schema, "total_interest_income", "income_statement", company_type
)
assert "us-gaap:InterestIncomeExpenseNet" not in interest
assert "ifrs-full:InterestRevenueExpense" not in interest
if company_type == "financial":
assert not {
"us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax",
"us-gaap:RevenueFromContractWithCustomerIncludingAssessedTax",
"ifrs-full:RevenueFromContractsWithCustomers",
} & set(revenue)
if company_type == "insurance":
investment = self._names(
schema,
"investment_securities_interest_income",
"income_statement",
company_type,
)
assert investment[0] == "us-gaap:NetInvestmentIncome"
row = schema.get_row(
"total_interest_income", "income_statement", company_type
)
assert row.parent == "investment_securities_interest_income"
def test_get_row_missing_returns_none(self, schema):
assert schema.get_row("does_not_exist", "balance_sheet", "industrial") is None
assert (
schema.get_tag_chain("does_not_exist", "balance_sheet", "industrial") == ()
)
def test_get_period_type(self, schema):
assert (
schema.get_period_type("total_assets", "balance_sheet", "industrial")
== "instant"
)
assert schema.get_period_type("nope", "balance_sheet", "industrial") is None
def test_extract_all_raises_for_annual_only_filer_on_quarterly(self, schema):
# A 20-F (annual-only) filer with no interim data -> OpenBBError for quarterly.
from openbb_core.app.model.abstract.error import OpenBBError
facts_json = {
"entityName": "Annual Only Inc.",
"cik": "0000000001",
"facts": _facts(
[
("ifrs-full", "Assets", "EUR", [_entry("2023-12-31", form="20-F")]),
(
"ifrs-full",
"Revenue",
"EUR",
[_entry("2023-12-31", "2023-01-01", form="20-F")],
),
]
),
}
with pytest.raises(OpenBBError):
schema.extract_all(facts_json, frequency="quarterly")
def test_merge_facts_combines_units(self, schema):
a = {
"facts": _facts(
[("us-gaap", "Assets", "USD", [_entry("2022-12-31", val=1.0)])]
)
}
b = {
"facts": _facts(
[("us-gaap", "Assets", "USD", [_entry("2023-12-31", val=2.0)])]
)
}
merged = schema.merge_facts(a, b)
assert len(merged["us-gaap"]["Assets"]["units"]["USD"]) == 2
# ---------------------------------------------------------------------------
# _detection.py
# ---------------------------------------------------------------------------
class TestGetFilingDatesSemiAnnual:
def test_semi_annual_150_to_200_day_window(self):
# A 6-K with a ~182-day (H1) duration must register via the
# 150<=days<=200 quarterly branch (line 127-130).
facts = _facts(
[
(
"us-gaap",
"Revenue",
"USD",
[_entry("2023-06-30", "2023-01-01", form="6-K")],
),
]
)
dates = get_filing_dates(facts, "quarterly")
assert "2023-06-30" in dates
def test_preliminary_quarterly_candidate_added(self):
# 8-K with a ~90 day duration only registers as a preliminary
# quarterly candidate when include_preliminary=True (line 131-136).
facts = _facts(
[
(
"us-gaap",
"Revenue",
"USD",
[_entry("2023-09-30", "2023-07-01", form="8-K")],
),
]
)
with_prelim = get_filing_dates(facts, "quarterly", include_preliminary=True)
assert "2023-09-30" in with_prelim
# Without the flag the 8-K quarter is ignored.
assert "2023-09-30" not in get_filing_dates(facts, "quarterly")
class TestGetFilingDatesCanonicalEdges:
def test_feb29_anchor_value_error_fallback(self):
# Dominant month/day is 02-29; for non-leap anchor years datetime()
# raises ValueError and the min(day, 28) fallback path runs (168-169).
rev = [
_entry("2020-02-29", "2019-03-01"),
_entry("2024-02-29", "2023-03-01"),
_entry("2016-02-29", "2015-03-01"),
_entry("2028-02-29", "2027-03-01"),
]
assets = [
_entry("2020-02-29"),
_entry("2024-02-29"),
_entry("2016-02-29"),
_entry("2028-02-29"),
]
facts = _facts(
[
("us-gaap", "Assets", "USD", assets),
("us-gaap", "Revenues", "USD", rev),
]
)
dates = get_filing_dates(facts, "annual")
# All four leap-day ends survive the canonical filter.
assert "2024-02-29" in dates
assert "2020-02-29" in dates
def test_earlier_near_duplicate_dropped_when_later_is_exact(self):
# Equal support within 10 days: the dominant anchor date is kept.
rev = [_entry(f"{y}-12-31", f"{y}-01-01") for y in (2021, 2022, 2023, 2024)]
assets = [_entry(f"{y}-12-31") for y in (2021, 2022, 2023, 2024)]
# A 52-week fiscal end a few days AFTER a calendar 12-31.
rev.append(_entry("2025-01-03", "2024-01-05"))
assets.append(_entry("2025-01-03"))
facts = _facts(
[
("us-gaap", "Assets", "USD", assets),
("us-gaap", "Revenues", "USD", rev),
]
)
dates = get_filing_dates(facts, "annual")
assert "2024-12-31" in dates
def test_later_near_duplicate_dropped_when_earlier_is_exact(self):
# Equal support within 10 days: the dominant anchor date is kept.
rev = [_entry(f"{y}-12-31", f"{y}-01-01") for y in (2020, 2021, 2022, 2023)]
assets = [_entry(f"{y}-12-31") for y in (2020, 2021, 2022, 2023)]
rev.append(_entry("2024-01-02", "2023-01-04"))
assets.append(_entry("2024-01-02"))
facts = _facts(
[
("us-gaap", "Assets", "USD", assets),
("us-gaap", "Revenues", "USD", rev),
]
)
dates = get_filing_dates(facts, "annual")
assert "2023-12-31" in dates
assert "2024-01-02" not in dates
def test_near_duplicate_cluster_keeps_best_supported_date(self):
# 2023-09-30 carries more annual facts than the 09-24 anchor and 09-25 strays.
ends = ("2020-09-24", "2021-09-24", "2022-09-24")
rev = [_entry(e, f"{int(e[:4]) - 1}-09-25") for e in ends]
ni = [_entry(e, f"{int(e[:4]) - 1}-09-25") for e in ends]
assets = [_entry(e) for e in ends]
rev += [
_entry("2023-09-24", "2022-09-25"),
_entry("2023-09-25", "2022-09-26"),
_entry("2023-09-30", "2022-10-01"),
]
ni.append(_entry("2023-09-30", "2022-10-01"))
cogs = [_entry("2023-09-30", "2022-10-01")]
assets.append(_entry("2023-09-30"))
facts = _facts(
[
("us-gaap", "Assets", "USD", assets),
("us-gaap", "Revenues", "USD", rev),
("us-gaap", "NetIncomeLoss", "USD", ni),
("us-gaap", "CostOfRevenue", "USD", cogs),
]
)
dates = get_filing_dates(facts, "annual")
assert "2023-09-30" in dates
assert "2023-09-24" not in dates
assert "2023-09-25" not in dates
class TestGetFiscalMetaEdges:
def test_annual_entry_without_filed_skipped(self):
# An annual entry that has no `filed` is skipped (line 266-267);
# metadata then falls back to int(date[:4]).
facts = _facts(
[
("us-gaap", "Assets", "USD", [_entry("2023-12-31", filed="")]),
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", filed="")],
),
]
)
meta = get_fiscal_meta(facts, "annual", {"2023-12-31"})
assert meta["2023-12-31"] == {"fiscal_year": 2023, "fiscal_period": "FY"}
def test_quarterly_entry_missing_fy_fp_skipped(self):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-09-30", "2023-07-01", form="10-Q", fy=None, fp="")],
),
]
)
meta = get_fiscal_meta(facts, "quarterly", {"2023-09-30"})
assert meta["2023-09-30"] == {"fiscal_year": 2023, "fiscal_period": "Q3"}
def test_semi_annual_metadata_used_for_quarter(self):
# A 6-K H1 interim supplies fiscal metadata via the SEMI_ANNUAL
# branch (279-283) and is read back through best_semi (328-333).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-06-30",
"2023-01-01",
form="6-K",
fy=2023,
fp="H1",
)
],
),
]
)
meta = get_fiscal_meta(facts, "quarterly", {"2023-06-30"})
assert meta["2023-06-30"] == {"fiscal_year": 2023, "fiscal_period": "H1"}
def test_preliminary_with_fy_fp_metadata(self):
# An 8-K carrying explicit fy/fp populates best_preliminary via the
# first preliminary branch (285-290) and is read back (335-340).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-09-30",
"2023-07-01",
form="8-K",
fy=2023,
fp="Q3",
)
],
),
]
)
meta = get_fiscal_meta(facts, "quarterly", {"2023-09-30"})
assert meta["2023-09-30"] == {"fiscal_year": 2023, "fiscal_period": "Q3"}
def test_semi_annual_half_year_classified_as_h1(self):
# A 6-K half-year (~181-day) interim is classified as H1 from its
# duration alone; the unreliable fy/fp fields are not required.
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-06-30",
"2023-01-01",
form="6-K",
fy=None,
fp="",
)
],
),
]
)
meta = get_fiscal_meta(facts, "quarterly", {"2023-06-30"})
assert meta["2023-06-30"] == {"fiscal_year": 2023, "fiscal_period": "H1"}
def test_preliminary_without_fy_fp_derives_calendar_quarter(self):
# An 8-K with no fy/fp derives a calendar quarter from the end
# month (line 291-298): 2023-09-30 -> Q3.
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-09-30",
"2023-07-01",
form="8-K",
fy=None,
fp="",
)
],
),
]
)
meta = get_fiscal_meta(facts, "quarterly", {"2023-09-30"})
assert meta["2023-09-30"] == {"fiscal_year": 2023, "fiscal_period": "Q3"}
def test_semi_annual_only_annual_date_becomes_h2(self):
# No 10-Q data, only 6-K interims: an annual date in a quarterly
# extraction is labelled H2 when non-annual dates do NOT outnumber
# annual dates (line 312-314).
facts = _facts(
[
(
"us-gaap",
"Assets",
"USD",
[
_entry("2023-06-30", form="6-K", fy=2023, fp="H1"),
_entry("2023-12-31", form="10-K", fy=2023, fp="FY"),
],
),
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-06-30",
"2023-01-01",
form="6-K",
fy=2023,
fp="H1",
),
_entry(
"2023-12-31",
"2023-01-01",
form="10-K",
fy=2023,
fp="FY",
),
],
),
]
)
dates = {"2023-06-30", "2023-12-31"}
meta = get_fiscal_meta(facts, "quarterly", dates)
# 1 non-annual (H1) is not > 1 annual -> H2.
assert meta["2023-12-31"]["fiscal_period"] == "H2"
def test_quarterly_fiscal_year_from_annual_filing(self):
# An annual date marked Q4 whose fiscal_year disagrees with the
# preceding interim's fiscal_year is realigned (line 366-370).
# Fiscal year ends 2024-01-31; the Q3 interim (2023-10-31) carries
# fy=2024, the FY end (2024-01-31) is mis-stamped fy=2023.
facts = _facts(
[
(
"us-gaap",
"Assets",
"USD",
[
_entry("2023-10-31", form="10-Q", fy=2024, fp="Q3"),
_entry("2024-01-31", form="10-K", fy=2023, fp="FY"),
],
),
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-10-31",
"2023-08-01",
form="10-Q",
fy=2024,
fp="Q3",
),
_entry(
"2024-01-31",
"2023-02-01",
form="10-K",
fy=2023,
fp="FY",
),
],
),
]
)
dates = get_filing_dates(facts, "quarterly")
meta = get_fiscal_meta(facts, "quarterly", dates)
assert meta["2024-01-31"]["fiscal_year"] == 2023
assert meta["2024-01-31"]["fiscal_period"] in ("Q4", "H2")
class TestDetectCurrencyTie:
def test_max_picks_a_currency_on_tie(self):
# Equal counts of two currencies -> max() still returns one of them
# (deterministic, exercises the non-empty return at line 398).
facts = _facts(
[
("us-gaap", "A", "USD", [_entry("2023-12-31")]),
("us-gaap", "B", "JPY", [_entry("2023-12-31")]),
]
)
assert detect_reporting_currency(facts) in {"USD", "JPY"}
# ---------------------------------------------------------------------------
# _extraction.py
# ---------------------------------------------------------------------------
class TestGetAnnualValuesEdges:
def test_missing_tag_appends_empty_candidate(self):
# A tag absent from facts hits the `if not tag_data` continue (72-73);
# the second (present) tag still resolves.
facts = _facts(
[
(
"us-gaap",
"RevB",
"USD",
[_entry("2023-12-31", "2023-01-01", val=200.0)],
)
]
)
row = _rd("total_revenue", xbrl=[("RevA", "us-gaap"), ("RevB", "us-gaap")])
res = _get_annual_values(facts, row)
assert res["2023-12-31"][1] == 200.0
def test_tag_present_but_no_matching_unit(self):
# Tag exists but only under a non-monetary unit -> _get_unit_data
# falls back to first unit; here we force an empty units dict so the
# `if not unit_data` branch (line 71-73) runs.
facts = {"us-gaap": {"RevA": {"units": {}}}}
row = _rd("total_revenue", xbrl=[("RevA", "us-gaap")])
assert _get_annual_values(facts, row) == {}
def test_unparseable_dates_skipped(self):
# A non-date end/start raises in strptime -> entry skipped (96-97).
facts = _facts(
[("us-gaap", "Revenues", "USD", [_entry("bad-date", "worse", val=5.0)])]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert _get_annual_values(facts, row) == {}
def test_none_value_skipped(self):
# An annual-window entry whose val is None is skipped (line 104-105).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", val=None)],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert _get_annual_values(facts, row) == {}
def test_date_present_only_in_skipped_candidate(self):
# all_dates collects a date, but every filing for it was filtered so
# ref_filed stays None -> the `if ref_filed is None: continue` at
# line 136-137 runs. Achieved by a val=None entry on a lone tag.
facts = {
"us-gaap": {
"Revenues": {
"units": {
"USD": [
{
"end": "2023-12-31",
"start": "2023-01-01",
"val": 10.0,
"form": "10-K",
"filed": "2024-02-15",
}
]
}
}
}
}
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
# Sanity: this one resolves.
assert _get_annual_values(facts, row)["2023-12-31"][1] == 10.0
class TestGetYtd9ValuesEdges:
def test_missing_tag_continue(self):
# First tag missing -> `if not tag_data: continue` (168-169); second
# tag supplies both the annual and the 9-month YTD entries.
facts = _facts(
[
(
"us-gaap",
"RevB",
"USD",
[
_entry("2023-12-31", "2023-01-01", val=1000.0),
_entry("2023-09-30", "2023-01-01", val=700.0),
],
),
]
)
row = _rd("total_revenue", xbrl=[("RevA", "us-gaap"), ("RevB", "us-gaap")])
res = _get_ytd9_values(facts, row)
assert res["2023-12-31"] == 300.0
def test_empty_units_continue(self):
# Tag present but units empty -> `if not unit_data: continue` (173-174).
facts = {"us-gaap": {"Rev": {"units": {}}}}
row = _rd("total_revenue", xbrl=[("Rev", "us-gaap")])
assert _get_ytd9_values(facts, row) == {}
def test_zero_length_duration_skipped(self):
# start == end inside the YTD loop -> skipped (line 180-181); leaves
# no annual entries so result is empty.
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-12-31", val=1000.0)],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert _get_ytd9_values(facts, row) == {}
def test_unparseable_and_none_value_skipped(self):
# Bad date (188-189) and None val (193-194) inside the YTD loop.
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry("bad", "worse", val=10.0),
_entry("2023-12-31", "2023-01-01", val=None),
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert _get_ytd9_values(facts, row) == {}
def test_no_ytd_match_continue(self):
# Annual present and a 9-month YTD present but the YTD end does NOT
# fall strictly inside any (fy_start, fy_end) window, so ytd_end_match
# stays None -> `if not ytd_end_match: continue` (229-230). The YTD
# belongs to a different fiscal year than the only annual.
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry("2023-12-31", "2023-01-01", val=1000.0),
# 9-mo YTD ending in a LATER year -> outside 2023 window.
_entry("2024-09-30", "2024-01-01", val=700.0),
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert _get_ytd9_values(facts, row) == {}
def test_no_common_filing_uses_latest_each(self):
# FY and YTD filed on different dates (no common filing) -> the else
# branch picks latest of each (line 241-243).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-12-31",
"2023-01-01",
filed="2024-02-10",
val=1000.0,
),
_entry(
"2023-09-30",
"2023-01-01",
filed="2023-11-05",
val=700.0,
),
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
res = _get_ytd9_values(facts, row)
assert res["2023-12-31"] == 300.0
class TestExtractRowValuesEdges:
def test_missing_tag_quarterly_appends_empty_ytd_candidate(self):
# Quarterly duration row with collect_ytd True: a missing first tag
# appends empty entries to BOTH tag_candidates and ytd_tag_candidates
# (line 278-284 / 289-294 region).
facts = _facts(
[
(
"us-gaap",
"RevB",
"USD",
[_entry("2023-03-31", "2023-01-01", form="10-Q", val=100.0)],
),
]
)
row = _rd("total_revenue", xbrl=[("RevA", "us-gaap"), ("RevB", "us-gaap")])
vals, _ = extract_row_values(facts, row, "quarterly", "USD")
assert vals.get("2023-03-31") == 100.0
def test_empty_units_quarterly_appends_empty_ytd_candidate(self):
# Tag present but empty units, quarterly + collect_ytd -> the
# `if not unit_data` branch appends empty ytd candidate (289-294).
facts = {
"us-gaap": {
"RevA": {"units": {}},
"RevB": {
"units": {
"USD": [
_entry("2023-03-31", "2023-01-01", form="10-Q", val=100.0)
]
}
},
}
}
row = _rd("total_revenue", xbrl=[("RevA", "us-gaap"), ("RevB", "us-gaap")])
vals, _ = extract_row_values(facts, row, "quarterly", "USD")
assert vals.get("2023-03-31") == 100.0
def test_entry_without_end_skipped(self):
# An entry with no `end` is skipped (line 308-309).
facts = {
"us-gaap": {
"Revenues": {
"units": {
"USD": [
{
"start": "2023-01-01",
"val": 5.0,
"form": "10-K",
"filed": "2024-02-15",
}
]
}
}
}
}
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert extract_row_values(facts, row, "annual", "USD") == ({}, {})
def test_unparseable_duration_skipped_annual(self):
# Bad date in duration branch -> strptime raises -> skipped (323-324).
facts = _facts(
[("us-gaap", "Revenues", "USD", [_entry("bad", "worse", val=5.0)])]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert extract_row_values(facts, row, "annual", "USD") == ({}, {})
def test_semi_annual_h1_window_quarterly(self):
# 6-K H1 (~181 days) in quarterly mode registers via the
# SEMI_ANNUAL_FORMS window branch (line 329-331).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-06-30", "2023-01-01", form="6-K", val=400.0)],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, _ = extract_row_values(facts, row, "quarterly", "USD")
assert vals.get("2023-06-30") == 400.0
def test_semi_annual_nine_month_extracted(self):
# A 6-K with a ~273-day (9-month) duration falls in the SEMI_ANNUAL
# window (240..310) and is extracted as a cumulative interim value.
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-09-30", "2023-01-01", form="6-K", val=700.0)],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, _ = extract_row_values(facts, row, "quarterly", "USD")
assert vals.get("2023-09-30") == 700.0
def test_none_value_skipped_after_window(self):
# A within-window annual entry whose val is None is skipped (348-349).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", val=None)],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert extract_row_values(facts, row, "annual", "USD") == ({}, {})
def test_quarterly_ytd_derivation_fills_missing_quarter(self):
# Q1 + Q2 standalone present, Q3 only as a 9-month YTD -> Q3 derived
# as ytd - prev_cum (line 467-543 YTD reconstruction path), and the
# ref-resolution `ref is None`/fallback branches (471-493) execute.
entries = [
# FY anchor (needed for fy_boundaries).
_entry("2023-12-31", "2023-01-01", form="10-K", val=1000.0),
# Standalone quarters from 10-Q vintage.
_entry("2023-03-31", "2023-01-01", form="10-Q", val=200.0),
_entry("2023-06-30", "2023-04-01", form="10-Q", val=250.0),
# 9-month YTD (Jan..Sep) -> 273 days, in the 136..310 YTD band.
_entry("2023-09-30", "2023-01-01", form="10-Q", val=750.0),
]
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, srcs = extract_row_values(facts, row, "quarterly", "USD")
# Q3 derived = 750 (YTD9) - (200 + 250) = 300.
assert vals.get("2023-09-30") == 300.0
assert "ytd_derived" in srcs.get("2023-09-30", "")
def test_quarterly_first_ytd_past_first_quarter_is_no_quarter(self):
entries = [
_entry("2023-12-31", "2023-01-01", form="10-K", val=1000.0),
_entry("2023-06-30", "2023-01-01", form="10-Q", val=500.0),
]
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, _ = extract_row_values(facts, row, "quarterly", "USD")
assert vals == {}
@pytest.mark.parametrize(
("first", "expected_q3"),
[
(_entry("2023-06-30", "2023-01-01", form="10-Q", val=500.0), 250.0),
(_entry("2023-03-31", "2023-01-01", form="10-Q", val=200.0), None),
(None, None),
],
)
def test_quarterly_ytd_quarter_needs_preceding_quarter(self, first, expected_q3):
entries = [
_entry("2023-12-31", "2023-01-01", form="10-K", val=1000.0),
_entry("2023-09-30", "2023-01-01", form="10-Q", val=750.0),
*([first] if first else []),
]
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, srcs = extract_row_values(facts, row, "quarterly", "USD")
assert "2023-06-30" not in vals
assert vals.get("2023-09-30") == expected_q3
assert vals["2023-12-31"] == 250.0
assert srcs["2023-12-31"] == (
"Q4: FY[us-gaap:Revenues] \u2212 9M[us-gaap:Revenues]"
)
@pytest.mark.parametrize(("goodwill_fy", "expected"), [(164.0, 0.0), (None, None)])
def test_q4_from_nine_months_on_one_concept(self, goodwill_fy, expected):
goodwill = [
_entry("2025-09-30", "2025-01-01", form="10-Q", val=164.0),
_entry("2025-09-30", "2025-07-01", form="10-Q", val=164.0),
]
if goodwill_fy is not None:
goodwill.append(_entry("2025-12-31", "2025-01-01", val=goodwill_fy))
facts = _facts(
[
(
"us-gaap",
"AssetImpairmentCharges",
"USD",
[_entry("2025-12-31", "2025-01-01", val=26.0)],
),
("us-gaap", "GoodwillImpairmentLoss", "USD", goodwill),
]
)
row = _rd(
"impairment_expense",
xbrl=[
("AssetImpairmentCharges", "us-gaap"),
("GoodwillImpairmentLoss", "us-gaap"),
],
)
vals, srcs = extract_row_values(facts, row, "quarterly", "USD")
assert vals.get("2025-12-31") == expected
if expected is not None:
assert srcs["2025-12-31"] == (
"Q4: FY[us-gaap:GoodwillImpairmentLoss] \u2212 "
"9M[us-gaap:GoodwillImpairmentLoss]"
)
def test_quarterly_ytd_with_shifted_start_uses_prior_quarters(self):
entries = [
_entry("2023-12-31", "2023-01-01", form="10-K", val=1220.0),
_entry("2023-03-31", "2023-01-01", form="10-Q", val=305.0),
_entry("2023-06-30", "2023-01-03", form="10-Q", val=610.0),
_entry("2023-09-30", "2023-01-01", form="10-Q", val=915.0),
]
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
vals, srcs = extract_row_values(facts, row, "quarterly", "USD")
assert vals["2023-06-30"] == 305.0
assert vals["2023-09-30"] == 305.0
assert vals["2023-12-31"] == 305.0
assert srcs["2023-09-30"] == "ytd_derived(us-gaap:Revenues)"
def test_quarterly_ytd_with_ref_filed_map_fallback(self):
# Same YTD reconstruction but driving the ref_filed_map branch where
# the requested ref is absent for the YTD date -> the
# `before/min(ytd_fdata)` fallback (line 492-494) runs.
entries = [
_entry(
"2023-12-31", "2023-01-01", form="10-K", filed="2024-02-15", val=1000.0
),
_entry(
"2023-03-31", "2023-01-01", form="10-Q", filed="2023-05-01", val=200.0
),
_entry(
"2023-06-30", "2023-04-01", form="10-Q", filed="2023-08-01", val=250.0
),
_entry(
"2023-09-30", "2023-01-01", form="10-Q", filed="2023-11-01", val=750.0
),
]
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
# ref_filed_map points the YTD date at a filing that does not exist.
ref_map = {
"2023-03-31": "2023-05-01",
"2023-06-30": "2023-08-01",
"2023-09-30": "2099-01-01",
"2023-12-31": "2024-02-15",
}
vals, srcs = extract_row_values(
facts, row, "quarterly", "USD", ref_filed_map=ref_map
)
assert vals.get("2023-09-30") == 300.0
def test_quarterly_ytd_ref_map_missing_date_skips_resolution(self):
# ref_filed_map omits the YTD date entirely -> `ref = map.get(date)`
# is None and the YTD candidate's `if ref is None: continue` (480-481)
# runs, so no ytd_derived value is produced for that date.
entries = [
_entry(
"2023-12-31", "2023-01-01", form="10-K", filed="2024-02-15", val=1000.0
),
_entry(
"2023-03-31", "2023-01-01", form="10-Q", filed="2023-05-01", val=200.0
),
_entry(
"2023-06-30", "2023-04-01", form="10-Q", filed="2023-08-01", val=250.0
),
_entry(
"2023-09-30", "2023-01-01", form="10-Q", filed="2023-11-01", val=750.0
),
]
facts = _facts([("us-gaap", "Revenues", "USD", entries)])
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
# Deliberately leave 2023-09-30 (the YTD date) out of the map.
ref_map = {
"2023-03-31": "2023-05-01",
"2023-06-30": "2023-08-01",
"2023-12-31": "2024-02-15",
}
vals, srcs = extract_row_values(
facts, row, "quarterly", "USD", ref_filed_map=ref_map
)
# Q3 was not resolved via the YTD path (its ref was None).
assert "ytd_derived" not in srcs.get("2023-09-30", "")
class TestComputeRefFilingsEdges:
def test_missing_tag_continue(self):
# A row tag absent from facts -> `if not tag_data: continue` (614).
# Provided indirectly: row chains two tags, first missing.
facts = _facts(
[
(
"us-gaap",
"RevB",
"USD",
[_entry("2023-12-31", "2023-01-01", filed="2024-02-01")],
)
]
)
row = _rd("total_revenue", xbrl=[("RevA", "us-gaap"), ("RevB", "us-gaap")])
ref = compute_ref_filings(facts, [row], "annual", "USD")
assert ref["2023-12-31"] == "2024-02-01"
def test_empty_units_continue(self):
# Tag present but empty units -> `if not unit_data: continue` (619-620).
facts = {
"us-gaap": {
"RevA": {"units": {}},
"RevB": {
"units": {
"USD": [_entry("2023-12-31", "2023-01-01", filed="2024-02-01")]
}
},
}
}
row = _rd("total_revenue", xbrl=[("RevA", "us-gaap"), ("RevB", "us-gaap")])
ref = compute_ref_filings(facts, [row], "annual", "USD")
assert ref["2023-12-31"] == "2024-02-01"
def test_entry_without_end_skipped(self):
# Entry lacking `end` -> skipped at line 630-631.
facts = {
"us-gaap": {
"Revenues": {
"units": {
"USD": [
{
"start": "2023-01-01",
"val": 1.0,
"form": "10-K",
"filed": "2024-02-01",
}
]
}
}
}
}
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert compute_ref_filings(facts, [row], "annual", "USD") == {}
def test_unparseable_duration_skipped(self):
# Bad date in duration branch -> strptime raises -> skipped (644-645).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("bad", "worse", filed="2024-02-01")],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert compute_ref_filings(facts, [row], "annual", "USD") == {}
def test_semi_annual_h1_window_quarterly(self):
# 6-K H1 (~181 days) accepted via SEMI_ANNUAL window (line 650-652).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-06-30", "2023-01-01", form="6-K", filed="2023-08-01"
)
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
ref = compute_ref_filings(facts, [row], "quarterly", "USD")
assert ref["2023-06-30"] == "2023-08-01"
def test_semi_annual_nine_month_produces_ref(self):
# A 6-K with a ~273-day (9-month) duration falls in the SEMI_ANNUAL
# window (240..310) and contributes a reference filing.
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-09-30", "2023-01-01", form="6-K", filed="2023-11-01"
)
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert compute_ref_filings(facts, [row], "quarterly", "USD") == {
"2023-09-30": "2023-11-01"
}
def test_out_of_window_quarterly_duration_skipped(self):
# A 10-Q-vintage duration of ~273 days is neither 60..135 (quarter)
# nor a semi-annual form -> rejected by `elif not 60<=days<=135`
# (line 653-654) leaving no ref.
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2023-09-30", "2023-01-01", form="10-Q", filed="2023-11-01"
)
],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert compute_ref_filings(facts, [row], "quarterly", "USD") == {}
def test_entry_without_filed_skipped(self):
# An entry with no `filed` -> skipped (line 658-659).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", filed="")],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert compute_ref_filings(facts, [row], "annual", "USD") == {}
def test_unparseable_filed_gap_skipped(self):
# A non-pit-mode entry whose `filed` is unparseable raises in the gap
# computation -> skipped (line 667-668).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", filed="not-a-date")],
)
]
)
row = _rd("total_revenue", xbrl=[("Revenues", "us-gaap")])
assert compute_ref_filings(facts, [row], "annual", "USD") == {}
# ---------------------------------------------------------------------------
# _schema.py -- drive StatementSchema.extract() directly.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def schema():
return StatementSchema()
def test_combine_unfunded_provision_skips_none_add(schema):
row = RowResult(
tag="provision_for_credit_losses",
label="Provision for credit losses",
description="",
parent=None,
sequence=1,
factor="+",
balance="debit",
unit="monetary",
period_type="duration",
values={"2024-12-31": 100.0},
sources={
"2024-12-31": "us-gaap:FinancingReceivableExcludingAccruedInterestCreditLossExpenseReversal"
},
)
with patch(
"openbb_sec.utils.statement_schema._schema.extract_row_values",
return_value=({}, {}),
):
schema._combine_unfunded_provision(
[row],
"income_statement",
"financial",
{"us-gaap": {}},
"annual",
"USD",
{},
)
assert row.values["2024-12-31"] == 100.0
class TestSchemaExtractDefaults:
def test_extract_infers_type_dates_and_ref_map(self, schema):
# Calling extract() with company_type / filing_dates / ref_filed_map
# all None drives the inference branches (line 229-235, 246-254).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", form="10-K", val=500.0 * _M)],
),
(
"us-gaap",
"CostsAndExpenses",
"USD",
[_entry("2023-12-31", "2023-01-01", form="10-K", val=300.0 * _M)],
),
]
)
res = schema.extract({"facts": facts}, "income_statement", frequency="annual")
assert res.statement == "income_statement"
assert res.currency == "USD"
assert "2023-12-31" in res.dates
def test_extract_quarterly_computes_annual_ref_map(self, schema):
# Quarterly extract() with no maps computes the annual reference map for Q4.
entries = [
_entry("2023-03-31", "2023-01-01", form="10-Q", val=100.0 * _M),
_entry("2023-06-30", "2023-04-01", form="10-Q", val=150.0 * _M),
_entry("2023-09-30", "2023-07-01", form="10-Q", val=200.0 * _M),
_entry("2023-12-31", "2023-01-01", form="10-K", val=600.0 * _M),
]
facts = _facts(
[
("us-gaap", "Revenues", "USD", entries),
(
"us-gaap",
"Assets",
"USD",
[
_entry("2023-03-31", form="10-Q"),
_entry("2023-06-30", form="10-Q"),
_entry("2023-09-30", form="10-Q"),
_entry("2023-12-31", form="10-K"),
],
),
]
)
res = schema.extract(
{"facts": facts}, "income_statement", frequency="quarterly"
)
rev = next(r for r in res.rows if r.tag == "total_revenue")
assert rev.values["2023-12-31"] == 150.0 * _M
assert rev.sources["2023-12-31"].startswith("Q4: FY[us-gaap:Revenues]")
def test_extract_include_preliminary_marks_dates(self, schema):
# include_preliminary populates _preliminary_dates and tags sources
# with the "preliminary:" prefix (line 237-240, 431-438).
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2024-12-31",
"2024-01-01",
form="10-K",
filed="2025-02-15",
val=500.0 * _M,
),
_entry(
"2025-12-31",
"2025-01-01",
form="8-K",
filed="2026-01-30",
val=600.0 * _M,
),
],
),
(
"us-gaap",
"CostsAndExpenses",
"USD",
[
_entry(
"2024-12-31",
"2024-01-01",
form="10-K",
filed="2025-02-15",
val=300.0 * _M,
),
_entry(
"2025-12-31",
"2025-01-01",
form="8-K",
filed="2026-01-30",
val=350.0 * _M,
),
],
),
]
)
res = schema.extract(
{"facts": facts},
"income_statement",
frequency="annual",
include_preliminary=True,
)
# The 8-K-only 2025 date is preliminary.
assert "2025-12-31" in res.preliminary_dates
rev = next(r for r in res.rows if r.tag == "total_revenue")
assert rev.sources["2025-12-31"].startswith("preliminary:")
class TestSchemaExtractCashFlow:
def _cf_facts(self, *, nc_val, eop_vals):
"""Build cash-flow facts: 3 activities + fx + net_change + EOP cash.
``eop_vals`` maps end-date -> EOP cash instant value.
"""
op = "NetCashProvidedByUsedInOperatingActivities"
inv = "NetCashProvidedByUsedInInvestingActivities"
fin = "NetCashProvidedByUsedInFinancingActivities"
fx = "EffectOfExchangeRateOnCashAndCashEquivalents"
ncd = "CashAndCashEquivalentsPeriodIncreaseDecrease"
eop = "CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents"
tags = []
for d in eop_vals:
y = d[:4]
start = f"{y}-01-01"
tags.extend(
[
(
"us-gaap",
op,
"USD",
[_entry(d, start, form="10-K", val=500.0 * _M)],
),
(
"us-gaap",
inv,
"USD",
[_entry(d, start, form="10-K", val=-200.0 * _M)],
),
(
"us-gaap",
fin,
"USD",
[_entry(d, start, form="10-K", val=-150.0 * _M)],
),
(
"us-gaap",
fx,
"USD",
[_entry(d, start, form="10-K", val=50.0 * _M)],
),
(
"us-gaap",
ncd,
"USD",
[_entry(d, start, form="10-K", val=nc_val)],
),
("us-gaap", eop, "USD", [_entry(d, form="10-K", val=eop_vals[d])]),
# Assets instant anchors the annual date resolution.
("us-gaap", "Assets", "USD", [_entry(d, form="10-K")]),
]
)
return _facts(tags)
def test_eop_standalone_backfill_and_bop_derivation(self, schema):
# Two consecutive years with EOP cash instants: the second year's BOP
# is derived from the prior year's EOP (line 347-358) and the EOP
# standalone backfill (line 336-345) populates eop from facts.
facts = self._cf_facts(
nc_val=200.0 * _M,
eop_vals={"2022-12-31": 1000.0 * _M, "2023-12-31": 1200.0 * _M},
)
res = schema.extract({"facts": facts}, "cash_flow", frequency="annual")
bop = next(
(r for r in res.rows if r.tag == "cash_at_beginning_of_period"), None
)
assert bop is not None
# BOP(2023) derived from EOP(2022) = 1000M.
assert bop.values.get("2023-12-31") == 1000.0 * _M
assert "derived: cash_at_end_of_period" in bop.sources["2023-12-31"]
def test_identity_enforced_on_bop_when_derived(self, schema):
# EOP - net_change != BOP(derived): because BOP source contains
# "derived:", BOP is rewritten to EOP - net_change (line 382-387).
# Year 1 EOP=1000, Year 2 EOP=1500, net_change=200 -> derived BOP
# (1000) violates 1000 + 200 != 1500 -> enforced to 1500-200=1300.
facts = self._cf_facts(
nc_val=200.0 * _M,
eop_vals={"2022-12-31": 1000.0 * _M, "2023-12-31": 1500.0 * _M},
)
res = schema.extract({"facts": facts}, "cash_flow", frequency="annual")
bop = next(r for r in res.rows if r.tag == "cash_at_beginning_of_period")
assert bop.values["2023-12-31"] == 1300.0 * _M
assert "identity-enforced" in bop.sources["2023-12-31"]
def test_net_change_corrected_when_activities_explain_cash_change(self, schema):
facts = self._cf_facts(
nc_val=999.0 * _M,
eop_vals={"2022-12-31": 1000.0 * _M, "2023-12-31": 1200.0 * _M},
)
res = schema.extract({"facts": facts}, "cash_flow", frequency="annual")
nc = next(r for r in res.rows if r.tag == "net_change_in_cash")
assert nc.values["2023-12-31"] == 200.0 * _M
assert nc.sources["2023-12-31"] == (
"corrected: cash_at_end_of_period - cash_at_beginning_of_period"
)
other = next(
(r for r in res.rows if r.tag == "other_net_changes_in_cash"), None
)
assert other is None or "2023-12-31" not in other.values
bop = next(r for r in res.rows if r.tag == "cash_at_beginning_of_period")
assert bop.values["2023-12-31"] == 1000.0 * _M
class TestSchemaExtractBalanceSheetAnchor:
def _facts(self, *, with_le):
tags = [
(
"us-gaap",
"Revenues",
"USD",
[
_entry(
"2022-12-31", "2022-01-01", filed="2023-02-15", val=900.0 * _M
)
],
),
(
"us-gaap",
"Assets",
"USD",
[
_entry("2022-12-31", filed="2023-02-15", val=300.0 * _M),
_entry("2022-12-31", filed="2024-02-15", val=300.0 * _M),
],
),
(
"us-gaap",
"Liabilities",
"USD",
[_entry("2022-12-31", filed="2023-02-15", val=200.0 * _M)],
),
(
"us-gaap",
"StockholdersEquity",
"USD",
[
_entry("2022-12-31", filed="2023-02-15", val=100.0 * _M),
_entry("2022-12-31", filed="2024-02-15", val=90.0 * _M),
],
),
]
if with_le:
tags.append(
(
"us-gaap",
"LiabilitiesAndStockholdersEquity",
"USD",
[_entry("2022-12-31", filed="2023-02-15", val=300.0 * _M)],
)
)
return _facts(tags)
def _row(self, res, tag):
return next(r for r in res.rows if r.tag == tag)
def test_liabilities_and_equity_filing_is_reference(self, schema):
res = schema.extract(
{"facts": self._facts(with_le=True)},
"balance_sheet",
frequency="annual",
company_type="industrial",
)
te = self._row(res, "total_equity")
assert te.values["2022-12-31"] == 100.0 * _M
assert te.sources["2022-12-31"] == "us-gaap:StockholdersEquity"
assert self._row(res, "total_liabilities").sources["2022-12-31"] == (
"us-gaap:Liabilities"
)
def test_anchor_overrides_supplied_reference_map(self, schema):
res = schema.extract(
{"facts": self._facts(with_le=True)},
"balance_sheet",
frequency="annual",
company_type="industrial",
ref_filed_map={"2022-12-31": "2024-02-15"},
)
assert self._row(res, "total_equity").values["2022-12-31"] == 100.0 * _M
def test_total_assets_filing_is_reference_without_liabilities_and_equity(
self, schema
):
res = schema.extract(
{"facts": self._facts(with_le=False)},
"balance_sheet",
frequency="annual",
company_type="industrial",
)
te = self._row(res, "total_equity")
assert te.values["2022-12-31"] == 90.0 * _M
assert te.sources["2022-12-31"] == "us-gaap:StockholdersEquity"
assert self._row(res, "total_liabilities").sources["2022-12-31"] == (
"us-gaap:Liabilities(fallback)"
)
class TestSchemaExtractChainSubsets:
def _facts(self, broad, narrow):
return _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", val=900.0 * _M)],
),
(
"us-gaap",
"ResearchAndDevelopmentExpense",
"USD",
[_entry("2023-12-31", "2023-01-01", val=broad)],
),
(
"us-gaap",
"ResearchAndDevelopmentExpenseExcludingAcquiredInProcessCost",
"USD",
[_entry("2023-12-31", "2023-01-01", val=narrow)],
),
]
)
def _rd_row(self, schema, facts):
res = schema.extract(
{"facts": facts},
"income_statement",
frequency="annual",
company_type="industrial",
)
return next(r for r in res.rows if r.tag == "rd_expense")
def test_larger_narrower_concept_replaces_partial_broader_value(self, schema):
rd = self._rd_row(schema, self._facts(100.0 * _M, 150.0 * _M))
assert rd.values["2023-12-31"] == 150.0 * _M
assert rd.sources["2023-12-31"] == (
"us-gaap:ResearchAndDevelopmentExpenseExcludingAcquiredInProcessCost"
)
def test_smaller_narrower_concept_keeps_broader_value(self, schema):
rd = self._rd_row(schema, self._facts(120.0 * _M, 110.0 * _M))
assert rd.values["2023-12-31"] == 120.0 * _M
assert rd.sources["2023-12-31"] == "us-gaap:ResearchAndDevelopmentExpense"
@pytest.mark.parametrize(
("revenues", "sales", "expected", "source"),
[
(0.0, 950.0 * _M, 950.0 * _M, "us-gaap:SalesRevenueNet"),
(1000.0 * _M, 950.0 * _M, 1000.0 * _M, "us-gaap:Revenues"),
],
)
def test_revenue_subset(self, schema, revenues, sales, expected, source):
facts = _facts(
[
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", val=revenues)],
),
(
"us-gaap",
"SalesRevenueNet",
"USD",
[_entry("2023-12-31", "2023-01-01", val=sales)],
),
]
)
res = schema.extract(
{"facts": facts},
"income_statement",
frequency="annual",
company_type="industrial",
)
row = next(r for r in res.rows if r.tag == "total_revenue")
assert row.values["2023-12-31"] == expected
assert row.sources["2023-12-31"] == source
@pytest.mark.parametrize(
("goods", "cost", "expected", "source"),
[
(33 * _M, 40199 * _M, 40199 * _M, "us-gaap:CostOfRevenue"),
(900 * _M, 800 * _M, 900 * _M, "us-gaap:CostOfGoodsAndServicesSold"),
],
)
def test_cost_of_revenue_subset(self, schema, goods, cost, expected, source):
facts = _facts(
[
(
"us-gaap",
"CostOfGoodsAndServicesSold",
"USD",
[_entry("2023-12-31", "2023-01-01", val=goods)],
),
(
"us-gaap",
"CostOfRevenue",
"USD",
[_entry("2023-12-31", "2023-01-01", val=cost)],
),
]
)
res = schema.extract(
{"facts": facts},
"income_statement",
frequency="annual",
company_type="industrial",
)
row = next(r for r in res.rows if r.tag == "total_cost_of_revenue")
assert row.values["2023-12-31"] == expected
assert row.sources["2023-12-31"] == source
def test_nonnegative_concept_reads_as_magnitude(self, schema):
entries = [
_entry("2023-03-31", "2023-01-01", form="10-Q", val=-100.0 * _M),
_entry("2023-06-30", "2023-04-01", form="10-Q", val=-110.0 * _M),
_entry("2023-06-30", "2023-01-01", form="10-Q", val=-210.0 * _M),
_entry("2023-09-30", "2023-07-01", form="10-Q", val=-120.0 * _M),
_entry("2023-12-31", "2023-01-01", form="10-K", val=-450.0 * _M),
]
facts = _facts(
[
("us-gaap", "ResearchAndDevelopmentExpense", "USD", entries),
(
"us-gaap",
"Assets",
"USD",
[
_entry("2023-03-31", form="10-Q"),
_entry("2023-06-30", form="10-Q"),
_entry("2023-09-30", form="10-Q"),
_entry("2023-12-31", form="10-K"),
],
),
]
)
quarterly = schema.extract(
{"facts": facts}, "income_statement", frequency="quarterly"
)
rd = next(r for r in quarterly.rows if r.tag == "rd_expense")
assert [rd.values[d] for d in sorted(rd.values)] == [
100.0 * _M,
110.0 * _M,
120.0 * _M,
120.0 * _M,
]
annual = schema.extract(
{"facts": facts}, "income_statement", frequency="annual"
)
rd = next(r for r in annual.rows if r.tag == "rd_expense")
assert rd.values["2023-12-31"] == 450.0 * _M
def test_net_position_depreciation_negative_not_taken(self, schema):
facts = _facts(
[
(
"us-gaap",
"DepreciationDepletionAndAmortization",
"USD",
[
_entry(
"2022-12-31",
"2022-01-01",
filed="2023-02-15",
val=-500.0 * _M,
),
_entry(
"2022-12-31",
"2022-01-01",
filed="2024-06-15",
val=500.0 * _M,
),
_entry("2023-03-31", "2023-01-01", form="10-Q", val=100.0 * _M),
_entry(
"2023-06-30", "2023-01-01", form="10-Q", val=-210.0 * _M
),
],
),
]
)
annual = schema.extract(
{"facts": facts}, "income_statement", frequency="annual"
)
da = next(r for r in annual.rows if r.tag == "depreciation_and_amortization")
assert da.values["2022-12-31"] == 500.0 * _M
quarterly = schema.extract(
{"facts": facts}, "income_statement", frequency="quarterly"
)
da = next(r for r in quarterly.rows if r.tag == "depreciation_and_amortization")
assert da.values["2023-03-31"] == 100.0 * _M
assert "2023-06-30" not in da.values
def test_eop_stale_filing_recovered_via_instant_fallback(self, schema):
# The EOP tag's only filing is stale (filed > 450 days after the
# period end) so compute_ref_filings excludes it from the shared ref
# map; the instant fallback in extract_row_values still recovers the
# value via min(filings). Exercises the cash_flow EOP/BOP wiring with
# a ref map driven by a separate (non-stale) operating-activity tag.
op = "NetCashProvidedByUsedInOperatingActivities"
eop = "CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents"
facts = _facts(
[
# Operating activity (non-stale) anchors both years' ref map.
(
"us-gaap",
op,
"USD",
[
_entry(
"2022-12-31",
"2022-01-01",
form="10-K",
filed="2023-02-15",
val=400.0 * _M,
),
_entry(
"2023-12-31",
"2023-01-01",
form="10-K",
filed="2024-02-15",
val=500.0 * _M,
),
],
),
# EOP cash for 2023 filed > 450 days late -> dropped from the
# shared ref map but recoverable standalone.
(
"us-gaap",
eop,
"USD",
[
_entry(
"2023-12-31",
form="10-K",
filed="2025-06-01",
val=1200.0 * _M,
)
],
),
(
"us-gaap",
"Assets",
"USD",
[
_entry("2022-12-31", form="10-K", filed="2023-02-15"),
_entry("2023-12-31", form="10-K", filed="2024-02-15"),
],
),
]
)
res = schema.extract({"facts": facts}, "cash_flow", frequency="annual")
eop_row = next((r for r in res.rows if r.tag == "cash_at_end_of_period"), None)
# The stale 2023 EOP was backfilled via the standalone extraction.
assert eop_row is not None
assert eop_row.values.get("2023-12-31") == 1200.0 * _M
def test_eop_standalone_backfill_when_date_absent_from_ref_map(self, schema):
# A date present in ``filing_dates`` but absent from the supplied
# ``ref_filed_map`` is skipped by the main per-row extraction (ref_filed
# resolves to None), so the standalone EOP backfill (lines 342-345)
# recovers the value straight from the instant fact.
eop = "CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents"
facts = _facts(
[
(
"us-gaap",
eop,
"USD",
[_entry("2023-12-31", form="10-K", val=1200.0 * _M)],
)
]
)
res = schema.extract(
{"facts": facts},
"cash_flow",
frequency="annual",
filing_dates={"2023-12-31"},
ref_filed_map={},
)
eop_row = next(r for r in res.rows if r.tag == "cash_at_end_of_period")
assert eop_row.values["2023-12-31"] == 1200.0 * _M
assert "CashCashEquivalents" in eop_row.sources["2023-12-31"]
class TestSchemaQuarterlyFiscalYearEnd:
def test_fiscal_year_end_kept_when_income_statement_lacks_it(self, schema):
m = 1_000_000
quarters = ("2023-03-31", "2023-06-30", "2023-09-30")
instants = ("2022-12-31", *quarters, "2023-12-31")
facts_json = {
"entityName": "Shifted Quarter Inc.",
"facts": _facts(
[
(
"us-gaap",
"Assets",
"USD",
[
_entry(d, form="10-K" if d.endswith("12-31") else "10-Q")
for d in instants
],
),
(
"us-gaap",
"CashAndCashEquivalentsAtCarryingValue",
"USD",
[
_entry(
d,
form="10-K" if d.endswith("12-31") else "10-Q",
val=10.0 * m,
)
for d in instants
],
),
(
"us-gaap",
"Revenues",
"USD",
[
_entry("2022-12-31", "2022-01-01", val=800.0 * m),
_entry("2023-12-31", "2023-01-01", val=1000.0 * m),
_entry(
"2023-03-31",
"2023-01-01",
form="10-Q",
fp="Q1",
val=200.0 * m,
),
_entry(
"2023-04-01",
"2023-01-02",
form="10-Q",
fp="Q1",
filed="2024-05-10",
val=200.0 * m,
),
_entry(
"2023-06-30",
"2023-04-01",
form="10-Q",
fp="Q2",
val=250.0 * m,
),
_entry(
"2023-09-30",
"2023-07-01",
form="10-Q",
fp="Q3",
val=300.0 * m,
),
],
),
]
),
}
results = schema.extract_all(facts_json, frequency="quarterly")
income = results["income_statement"]
assert "2023-12-31" in income.dates
revenue = next(r for r in income.rows if r.tag == "total_revenue")
assert revenue.values["2023-12-31"] == 250.0 * m
class TestSchemaExtractAllErrors:
def test_quarterly_no_dates_generic_error(self, schema):
# A plain 10-K filer (not 20-F/40-F) with no interim data yields no
# quarterly filing dates and is not "annual-only" by form, so
# extract_all raises the generic no-quarterly OpenBBError (line 493).
from openbb_core.app.model.abstract.error import OpenBBError
facts_json = {
"entityName": "Domestic Annual Inc.",
"facts": _facts(
[
("us-gaap", "Assets", "USD", [_entry("2023-12-31", form="10-K")]),
(
"us-gaap",
"Revenues",
"USD",
[_entry("2023-12-31", "2023-01-01", form="10-K")],
),
]
),
}
with pytest.raises(OpenBBError, match="No quarterly filing dates"):
schema.extract_all(facts_json, frequency="quarterly")
class TestSchemaMergeFactsMetadata:
def test_merge_carries_label_and_description(self, schema):
# merge_facts copies label/description from the first source that has
# them (line 617-621).
a = {
"facts": {
"us-gaap": {
"Assets": {
"label": "Total Assets",
"description": "Sum of assets.",
"units": {"USD": [_entry("2022-12-31", val=1.0)]},
}
}
}
}
b = {
"facts": {
"us-gaap": {
"Assets": {
"label": "Ignored",
"units": {"USD": [_entry("2023-12-31", val=2.0)]},
}
}
}
}
merged = schema.merge_facts(a, b)
assert merged["us-gaap"]["Assets"]["label"] == "Total Assets"
assert merged["us-gaap"]["Assets"]["description"] == "Sum of assets."
assert len(merged["us-gaap"]["Assets"]["units"]["USD"]) == 2