Files
OpenBB/openbb_platform/providers/sec/tests/test_company_facts.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

3271 lines
114 KiB
Python

"""Test company facts resolution and imputation logic."""
# flake8: noqa: D102,D103,D403
import asyncio
import json
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import patch
import pytest
from openbb_core.app.model.abstract.error import OpenBBError
import openbb_sec.utils.company_facts as cf
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 get_filing_dates
_FIXTURE_DIR = Path(__file__).parent / "record"
@pytest.fixture(scope="module")
def schema():
return StatementSchema()
@pytest.fixture(scope="module")
def blk_facts():
"""Load the BlackRock company-facts JSON fixture."""
with open(_FIXTURE_DIR / "CIK0002012383.json") as f:
return json.load(f)
@pytest.fixture(scope="module")
def blk_annual(blk_facts):
"""Annual extraction of BLK fixture."""
return resolve_company_facts(blk_facts, period="annual")
def _val(records, tag, date="2025-12-31"):
"""Get values for a specific tag/date."""
for r in records:
if r["tag"] == tag and r["period_ending"] == date:
return r["value"], r["source"]
return None, None
def create_mock_facts(entries: list):
facts = {}
for e in entries:
ns = e.get("ns", "us-gaap")
tag = e["tag"]
val = e["val"]
start = e.get("start")
end = e["end"]
form = e.get("form", "10-K")
filed = e.get(
"filed",
(datetime.strptime(end, "%Y-%m-%d") + timedelta(days=45)).strftime(
"%Y-%m-%d"
),
)
fy = e.get("fy", int(end[:4]))
# Determine fp if not provided.
if "fp" in e:
fp = e["fp"]
elif form == "10-Q":
# Just a simplistic inferring for the mock
if end.endswith("-03-31"):
fp = "Q1"
elif end.endswith("-06-30"):
fp = "Q2"
elif end.endswith("-09-30"):
fp = "Q3"
else:
fp = "Q4"
else:
fp = "FY"
if ns not in facts:
facts[ns] = {}
if tag not in facts[ns]:
facts[ns][tag] = {"units": {"USD": []}}
entry_data = {
"end": end,
"val": val,
"form": form,
"filed": filed,
"fy": fy,
"fp": fp,
}
if start:
entry_data["start"] = start
facts[ns][tag]["units"]["USD"].append(entry_data)
return {"cik": "0000000000", "entityName": "Mock Company Inc.", "facts": facts}
def test_company_type_detection(schema):
industrial = create_mock_facts(
[{"tag": "CostOfGoodsAndServicesSold", "val": 100, "end": "2023-12-31"}]
)
assert schema.detect_type(industrial["facts"]) == "industrial"
financial = create_mock_facts(
[
{"tag": "InterestIncomeExpenseNet", "val": 100, "end": "2023-12-31"},
{"tag": "NoninterestIncome", "val": 100, "end": "2023-12-31"},
]
)
assert schema.detect_type(financial["facts"]) == "financial"
insurance = create_mock_facts(
[
{"tag": "PremiumsEarnedNet", "val": 100, "end": "2023-12-31"},
{
"tag": "LiabilityForFuturePolicyBenefits",
"val": 100,
"end": "2023-12-31",
},
]
)
assert schema.detect_type(insurance["facts"]) == "insurance"
diversified = create_mock_facts(
[{"tag": "CostsAndExpenses", "val": 100, "end": "2023-12-31"}]
)
assert schema.detect_type(diversified["facts"]) == "diversified"
def test_basic_extraction_and_imputation():
def anchor_tags(year):
return [
{"tag": "Assets", "val": 1000, "end": f"{year}-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
},
]
mock_data = create_mock_facts(
anchor_tags(2023)
+ anchor_tags(2024)
+ [
{"tag": "Revenues", "val": 500, "start": "2023-01-01", "end": "2023-12-31"},
{
"tag": "CostOfGoodsAndServicesSold",
"val": 300,
"start": "2023-01-01",
"end": "2023-12-31",
},
{"tag": "Revenues", "val": 600, "start": "2024-01-01", "end": "2024-12-31"},
{
"tag": "CostOfGoodsAndServicesSold",
"val": 350,
"start": "2024-01-01",
"end": "2024-12-31",
},
]
)
res = resolve_company_facts(mock_data, period="annual")
is_records = res.income_statement
gp_2024 = [
r
for r in is_records
if r["tag"] == "total_gross_profit" and r["period_ending"] == "2024-12-31"
]
assert len(gp_2024) == 1
assert gp_2024[0]["value"] == 250.0
assert gp_2024[0]["source"] == "imputed: total_revenue - total_cost_of_revenue"
def test_suspect_zero_policy():
def anchor_tags(year):
return [
{"tag": "Assets", "val": 1000, "end": f"{year}-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
},
]
mock_data = create_mock_facts(
anchor_tags(2023)
+ [
{"tag": "Revenues", "val": 500, "start": "2023-01-01", "end": "2023-12-31"},
{
"tag": "CostOfGoodsAndServicesSold",
"val": 500,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock_data, period="annual")
gp_2023 = [
r
for r in res.income_statement
if r["tag"] == "total_gross_profit" and r["period_ending"] == "2023-12-31"
]
assert gp_2023[0]["value"] == 0.0
assert "imputed-zero" in gp_2023[0]["source"]
def test_corrected_mathematical_flaw_costs_and_expenses():
def anchor_tags(year):
return [
{"tag": "Assets", "val": 1000, "end": f"{year}-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
},
]
mock_data = create_mock_facts(
anchor_tags(2023)
+ [
{
"tag": "Revenues",
"val": 1000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "OperatingIncomeLoss",
"val": 200,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeBeforeEquityMethodInvestments",
"val": 300,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock_data, period="annual")
ce_2023 = [
r
for r in res.income_statement
if r["tag"] == "costs_and_expenses" and r["period_ending"] == "2023-12-31"
]
assert ce_2023[0]["value"] == 800.0
def test_q4_derivation_and_bs_immunity():
def q_anchor(start, end):
return [
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": start,
"end": end,
"form": "10-Q",
}
]
mock_data = create_mock_facts(
[
{"tag": "Assets", "val": 1000, "end": "2023-12-31", "form": "10-K"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 400,
"start": "2023-01-01",
"end": "2023-12-31",
"form": "10-K",
},
{
"tag": "Revenues",
"val": 100,
"start": "2023-01-01",
"end": "2023-03-31",
"form": "10-Q",
},
{"tag": "Assets", "val": 900, "end": "2023-03-31", "form": "10-Q"},
*q_anchor("2023-01-01", "2023-03-31"),
{
"tag": "Revenues",
"val": 150,
"start": "2023-04-01",
"end": "2023-06-30",
"form": "10-Q",
},
{"tag": "Assets", "val": 920, "end": "2023-06-30", "form": "10-Q"},
*q_anchor("2023-04-01", "2023-06-30"),
{
"tag": "Revenues",
"val": 200,
"start": "2023-07-01",
"end": "2023-09-30",
"form": "10-Q",
},
{"tag": "Assets", "val": 940, "end": "2023-09-30", "form": "10-Q"},
*q_anchor("2023-07-01", "2023-09-30"),
{
"tag": "Revenues",
"val": 600,
"start": "2023-01-01",
"end": "2023-12-31",
"form": "10-K",
},
]
)
res = resolve_company_facts(mock_data, period="quarterly")
is_q4 = [
r
for r in res.income_statement
if r["fiscal_period"] == "Q4" and r["tag"] == "total_revenue"
]
assert is_q4[0]["value"] == 150.0
assert "Q4:" in is_q4[0]["source"]
# Enriched Q4 source should include XBRL tags in brackets
assert "FY[" in is_q4[0]["source"]
assert "Q1[" in is_q4[0]["source"]
assert "Q2[" in is_q4[0]["source"]
assert "Q3[" in is_q4[0]["source"]
bs_q4 = [
r
for r in res.balance_sheet
if r["fiscal_period"] == "Q4" and r["tag"] == "total_assets"
]
assert bs_q4[0]["value"] == 1000.0
assert "Q4:" not in bs_q4[0]["source"]
def test_diagnostics_produced():
mock_data = create_mock_facts(
[
{"tag": "Assets", "val": 1000000000, "end": "2023-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
{"tag": "Revenues", "val": 500, "start": "2023-01-01", "end": "2023-12-31"},
{
"tag": "LiabilitiesAndStockholdersEquity",
"val": 900000000,
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock_data, period="annual")
warnings = [d for d in res.diagnostics if d.tag == "total_assets"]
assert warnings[0].actual == 1000000000.0
assert warnings[0].expected == 900000000.0
def test_cross_vintage_fallback():
mock_data = create_mock_facts(
[
{"tag": "Assets", "val": 1000, "end": "2023-12-31", "filed": "2024-03-01"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
"filed": "2024-03-01",
},
{
"tag": "Revenues",
"val": 500,
"start": "2023-01-01",
"end": "2023-12-31",
"filed": "2024-03-01",
},
{
"tag": "CostOfRevenue",
"val": 200,
"start": "2023-01-01",
"end": "2023-12-31",
"filed": "2025-03-01",
},
]
)
res = resolve_company_facts(mock_data, period="annual")
rev = [r for r in res.income_statement if r["tag"] == "total_revenue"]
assert rev[0]["value"] == 500.0
assert "(fallback)" in rev[0]["source"]
def test_deep_cascading_imputation():
def anchor_tags(year):
return [
{"tag": "Assets", "val": 1000, "end": f"{year}-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
},
]
mock_data = create_mock_facts(
anchor_tags(2023)
+ [
{
"tag": "Revenues",
"val": 1000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "CostOfGoodsAndServicesSold",
"val": 400,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "OperatingExpenses",
"val": 200,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeLossFromContinuingOperationsBeforeIncomeTaxesExtraordinaryItemsNoncontrollingInterest",
"val": 350,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeTaxExpenseBenefit",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeLossFromDiscontinuedOperationsNetOfTax",
"val": 0,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock_data, period="annual")
def get_val(tag_name):
matches = [
r
for r in res.income_statement
if r["tag"] == tag_name and r["period_ending"] == "2023-12-31"
]
return matches[0]["value"] if matches else None
assert get_val("total_revenue") == 1000.0
assert get_val("total_cost_of_revenue") == 400.0
assert get_val("total_gross_profit") == 600.0
assert get_val("total_operating_expenses") == 200.0
assert get_val("total_operating_income") == 400.0
assert get_val("total_other_income") == -50.0
assert get_val("total_pretax_income") == 350.0
assert get_val("net_income_continuing") == 250.0
assert get_val("net_income") == 250.0
def test_ifrs_namespace():
def anchor_tags(year):
return [
{
"ns": "ifrs-full",
"tag": "Assets",
"val": 1000,
"end": f"{year}-12-31",
"form": "20-F",
},
{
"ns": "ifrs-full",
"tag": "CashFlowsFromUsedInOperatingActivities",
"val": 100,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
"form": "20-F",
},
]
mock_data = create_mock_facts(
anchor_tags(2023)
+ [
{
"ns": "ifrs-full",
"tag": "Revenue",
"val": 1000,
"start": "2023-01-01",
"end": "2023-12-31",
"form": "20-F",
},
]
)
res = resolve_company_facts(mock_data, period="annual")
rev = [r for r in res.income_statement if r["tag"] == "total_revenue"]
assert rev[0]["value"] == 1000.0
def test_mixed_10k_10q_period_filtering():
def q_anchor(start, end, form):
return [
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 10,
"start": start,
"end": end,
"form": form,
}
]
mock_data = create_mock_facts(
[
{"tag": "Assets", "val": 1000, "end": "2023-12-31", "form": "10-K"},
*q_anchor("2023-01-01", "2023-12-31", "10-K"),
{
"tag": "Revenues",
"val": 400,
"start": "2023-01-01",
"end": "2023-12-31",
"form": "10-K",
},
{"tag": "Assets", "val": 900, "end": "2023-03-31", "form": "10-Q"},
*q_anchor("2023-01-01", "2023-03-31", "10-Q"),
{
"tag": "Revenues",
"val": 100,
"start": "2023-01-01",
"end": "2023-03-31",
"form": "10-Q",
},
]
)
res_annual = resolve_company_facts(mock_data, period="annual")
assert all(r["fiscal_period"] == "FY" for r in res_annual.balance_sheet)
res_quarterly = resolve_company_facts(mock_data, period="quarterly")
periods = {r["fiscal_period"] for r in res_quarterly.balance_sheet}
assert "Q1" in periods
# =========================================================================
# BLK fixture tests — real SEC XBRL data for BlackRock (CIK 0002012383)
# =========================================================================
class TestBLKCompanyType:
"""Company-type detection for BlackRock (diversified asset manager)."""
def test_detected_as_diversified(self, blk_annual):
assert blk_annual.company_type == "diversified"
def test_entity_metadata(self, blk_annual):
assert blk_annual.entity_name == "BlackRock, Inc."
assert blk_annual.currency == "USD"
class TestBLKZeroDiagnostics:
"""All accounting identities must hold — zero violations."""
def test_no_identity_violations(self, blk_annual):
assert len(blk_annual.diagnostics) == 0, (
f"Expected 0 diagnostics, got {len(blk_annual.diagnostics)}: "
+ "; ".join(
f"{d.tag}@{d.date}: expected={d.expected}, actual={d.actual}"
for d in blk_annual.diagnostics
)
)
class TestBLKDates:
"""Verify the expected reporting periods are present."""
def test_three_annual_dates(self, blk_annual):
dates = sorted(set(r["period_ending"] for r in blk_annual.income_statement))
assert dates == ["2023-12-31", "2024-12-31", "2025-12-31"]
def test_bs_dates_match_is(self, blk_annual):
is_dates = sorted(set(r["period_ending"] for r in blk_annual.income_statement))
bs_dates = sorted(set(r["period_ending"] for r in blk_annual.balance_sheet))
assert is_dates == bs_dates
def test_cf_dates_match_is(self, blk_annual):
is_dates = sorted(set(r["period_ending"] for r in blk_annual.income_statement))
cf_dates = sorted(set(r["period_ending"] for r in blk_annual.cash_flow))
assert is_dates == cf_dates
class TestBLKIncomeStatement:
"""Income statement XBRL tag selection, values, and imputation."""
# --- Direct XBRL extractions (tag selection) ---
def test_revenue_tag_selection(self, blk_annual):
v, s = _val(blk_annual.income_statement, "total_revenue")
assert v == 24_216_000_000
assert "RevenueFromContractWithCustomerExcludingAssessedTax" in s
def test_operating_income(self, blk_annual):
v, s = _val(blk_annual.income_statement, "total_operating_income")
assert v == 7_045_000_000
assert "OperatingIncomeLoss" in s
def test_pretax_income(self, blk_annual):
v, s = _val(blk_annual.income_statement, "total_pretax_income")
assert v == 7_619_000_000
def test_income_tax_expense(self, blk_annual):
v, s = _val(blk_annual.income_statement, "income_tax_expense")
assert v == 1_677_000_000
assert "IncomeTaxExpenseBenefit" in s
def test_net_income(self, blk_annual):
v, s = _val(blk_annual.income_statement, "net_income")
assert v == 5_942_000_000
assert "ProfitLoss" in s
def test_net_income_to_common(self, blk_annual):
v, s = _val(blk_annual.income_statement, "net_income_to_common")
assert v == 5_553_000_000
assert "NetIncomeLoss" in s
def test_sga_expense(self, blk_annual):
v, s = _val(blk_annual.income_statement, "sga_expense")
assert v == 2_731_000_000
assert "GeneralAndAdministrativeExpense" in s
def test_operating_expenses(self, blk_annual):
v, s = _val(blk_annual.income_statement, "total_operating_expenses")
assert v == 17_171_000_000
assert "OperatingExpenses" in s
def test_eps_values(self, blk_annual):
basic, _ = _val(blk_annual.income_statement, "basic_eps")
diluted, _ = _val(blk_annual.income_statement, "diluted_eps")
assert basic == 35.83
assert diluted == 35.31
def test_equity_method_investments(self, blk_annual):
v, s = _val(blk_annual.income_statement, "equity_method_investments")
assert v == 51_000_000
assert "IncomeLossFromEquityMethodInvestments" in s
# --- Imputed values ---
def test_costs_and_expenses_from_operating_expenses(self, blk_annual):
"""C&E takes the reported OperatingExpenses total; its other line holds the remainder."""
v, s = _val(blk_annual.income_statement, "costs_and_expenses")
assert v == 17_171_000_000
assert s == "us-gaap:OperatingExpenses"
sga, _ = _val(blk_annual.income_statement, "sga_expense")
restr, _ = _val(blk_annual.income_statement, "restructuring_charge")
other, s_other = _val(blk_annual.income_statement, "other_operating_expenses")
assert other == v - sga - restr
assert s_other.startswith("imputed-plug")
def test_income_before_equity_method(self, blk_annual):
"""income_before_equity_method from XBRL (same tag as total_pretax_income for BLK)."""
v, s = _val(blk_annual.income_statement, "income_before_equity_method")
assert v == 7_619_000_000
def test_comprehensive_income_nci_imputed(self, blk_annual):
"""comprehensive_income_nci = comprehensive_income - comprehensive_income_parent."""
v, s = _val(blk_annual.income_statement, "comprehensive_income_nci")
ci, _ = _val(blk_annual.income_statement, "comprehensive_income")
ci_p, _ = _val(blk_annual.income_statement, "comprehensive_income_parent")
assert v == ci - ci_p
assert "imputed" in s
def test_plug_rows_present(self, blk_annual):
"""Each total's other line holds its remainder."""
plugs = {
"other_operating_expenses",
"other_income",
"other_adjustments_to_net_income_to_common",
}
for tag in plugs:
v, s = _val(blk_annual.income_statement, tag)
assert v is not None, f"{tag} missing"
assert "imputed-plug" in s, f"{tag} should be a plug, got: {s}"
def test_pretax_articulates_without_remainder(self, blk_annual):
"""Pretax income is operating income plus non-operating income."""
pretax, _ = _val(blk_annual.income_statement, "total_pretax_income")
opinc, _ = _val(blk_annual.income_statement, "total_operating_income")
other, _ = _val(blk_annual.income_statement, "total_other_income")
assert pretax == opinc + other
assert _val(blk_annual.income_statement, "other_pretax_income") == (None, None)
def test_tax_decomposition(self, blk_annual):
"""Current + deferred tax should sum to total income tax expense."""
current, _ = _val(blk_annual.income_statement, "income_tax_current")
deferred, _ = _val(blk_annual.income_statement, "income_tax_deferred")
total, _ = _val(blk_annual.income_statement, "income_tax_expense")
assert current == 2_308_000_000
assert deferred == -631_000_000
assert current + deferred == total
class TestBLKBalanceSheet:
"""Balance sheet XBRL tag selection, values, and rollups."""
# --- Direct XBRL extractions ---
def test_total_assets(self, blk_annual):
v, s = _val(blk_annual.balance_sheet, "total_assets")
assert v == 169_998_000_000
assert "Assets" in s
def test_total_liabilities(self, blk_annual):
v, s = _val(blk_annual.balance_sheet, "total_liabilities")
assert v == 108_456_000_000
assert "Liabilities" in s
def test_total_equity(self, blk_annual):
v, s = _val(blk_annual.balance_sheet, "total_equity")
assert v == 55_888_000_000
assert "StockholdersEquity" in s
def test_total_liabilities_and_equity(self, blk_annual):
v, s = _val(blk_annual.balance_sheet, "total_liabilities_and_equity")
assert v == 169_998_000_000
assert "LiabilitiesAndStockholdersEquity" in s
def test_noncontrolling_interests(self, blk_annual):
v, s = _val(blk_annual.balance_sheet, "noncontrolling_interests")
assert v == 227_000_000
assert "MinorityInterest" in s
def test_goodwill(self, blk_annual):
v, s = _val(blk_annual.balance_sheet, "goodwill")
assert v == 35_283_000_000
def test_ppe_decomposition(self, blk_annual):
gross, _ = _val(blk_annual.balance_sheet, "gross_ppe")
accum, _ = _val(blk_annual.balance_sheet, "accumulated_depreciation")
net, _ = _val(blk_annual.balance_sheet, "net_ppe")
assert gross == 2_948_000_000
assert accum == 1_692_000_000
assert net == 1_256_000_000
assert gross - accum == net
# --- BS primary identity: Assets = Liabilities + Equity (+ mezzanine) ---
def test_assets_equal_liabilities_and_equity(self, blk_annual):
ta, _ = _val(blk_annual.balance_sheet, "total_assets")
tle, _ = _val(blk_annual.balance_sheet, "total_liabilities_and_equity")
assert ta == tle
def test_liabilities_plus_equity_plus_mezzanine(self, blk_annual):
"""L + ENCI + temporary equity = L&E."""
tl, _ = _val(blk_annual.balance_sheet, "total_liabilities")
teni, _ = _val(
blk_annual.balance_sheet, "total_equity_and_noncontrolling_interests"
)
mezz, _ = _val(blk_annual.balance_sheet, "temporary_equity")
tle, _ = _val(blk_annual.balance_sheet, "total_liabilities_and_equity")
assert tl + teni + mezz == tle
# --- Mezzanine equity (temporary equity) ---
def test_temporary_equity_is_liabilities_and_equity_remainder(self, blk_annual):
"""BLK mezzanine equity is the remainder of liabilities and equity."""
v, s = _val(blk_annual.balance_sheet, "temporary_equity")
assert v == 5_427_000_000
assert s.startswith("imputed-plug: total_liabilities_and_equity - ")
rnci, _ = _val(blk_annual.balance_sheet, "redeemable_noncontrolling_interest")
assert rnci is None
def test_mezzanine_leaves_no_liabilities_and_equity_remainder(self, blk_annual):
"""Redeemable NCI closes L&E, so no other line is created for it."""
other_le, _ = _val(blk_annual.balance_sheet, "other_liabilities_and_equity")
assert other_le is None
# --- Rollups ---
def test_total_common_equity_rollup(self, blk_annual):
"""total_common_equity is a rollup of equity components (with sign factors)."""
tce, s = _val(blk_annual.balance_sheet, "total_common_equity")
assert tce == 55_888_000_000
assert "imputed-rollup" in s
# Enriched source should list child tags
assert "(+)" in s # at least one child with factor shown
# Verify it matches total_equity (from XBRL)
te, _ = _val(blk_annual.balance_sheet, "total_equity")
assert tce == te
# --- Plug rows ---
def test_other_assets_plug(self, blk_annual):
v, s = _val(blk_annual.balance_sheet, "other_assets")
assert v == 86_969_000_000
assert "imputed-plug" in s
# Enriched source should reference parent tag and children
assert "total_assets" in s
def test_other_liabilities_plug(self, blk_annual):
v, s = _val(blk_annual.balance_sheet, "other_liabilities")
assert v == 87_240_000_000
assert "imputed-plug" in s
# Enriched source should reference parent tag and children
assert "total_liabilities" in s
class TestBLKCashFlow:
"""Cash flow statement values, identities, and imputation."""
# --- Direct XBRL extractions ---
def test_operating_activities(self, blk_annual):
v, s = _val(blk_annual.cash_flow, "net_cash_from_operating_activities")
assert v == 3_927_000_000
assert "NetCashProvidedByUsedInOperatingActivities" in s
def test_investing_activities(self, blk_annual):
v, s = _val(blk_annual.cash_flow, "net_cash_from_investing_activities")
assert v == -4_418_000_000
def test_financing_activities(self, blk_annual):
v, s = _val(blk_annual.cash_flow, "net_cash_from_financing_activities")
assert v == -1_127_000_000
def test_fx_effect(self, blk_annual):
v, s = _val(blk_annual.cash_flow, "effect_of_exchange_rate_changes")
assert v == 329_000_000
def test_net_change_in_cash(self, blk_annual):
v, s = _val(blk_annual.cash_flow, "net_change_in_cash")
assert v == -1_289_000_000
def test_depreciation(self, blk_annual):
v, s = _val(blk_annual.cash_flow, "depreciation_and_amortization")
assert v == 297_000_000
# --- CF identity: op + inv + fin + fx = net_change ---
def test_activity_sum_equals_net_change(self, blk_annual):
op, _ = _val(blk_annual.cash_flow, "net_cash_from_operating_activities")
inv, _ = _val(blk_annual.cash_flow, "net_cash_from_investing_activities")
fin, _ = _val(blk_annual.cash_flow, "net_cash_from_financing_activities")
fx, _ = _val(blk_annual.cash_flow, "effect_of_exchange_rate_changes")
nc, _ = _val(blk_annual.cash_flow, "net_change_in_cash")
assert op + inv + fin + fx == nc
# --- Cash bridge: begin + net_change = end ---
def test_cash_bridge(self, blk_annual):
begin, _ = _val(blk_annual.cash_flow, "cash_at_beginning_of_period")
end, _ = _val(blk_annual.cash_flow, "cash_at_end_of_period")
nc, _ = _val(blk_annual.cash_flow, "net_change_in_cash")
assert begin + nc == end
assert begin == 12_779_000_000
assert end == 11_490_000_000
# --- Identity lock: CF net_income comes from IS ---
def test_net_income_identity_lock(self, blk_annual):
v, s = _val(blk_annual.cash_flow, "net_income")
assert v == 5_942_000_000
assert "identity_lock" in s
# Enriched source should indicate which statement the lock came from
assert "identity_lock:cash_flow" in s
# --- Rollups ---
def test_operating_capital_rollup(self, blk_annual):
"""increase_decrease_in_operating_capital is a rollup of WC changes."""
v, s = _val(blk_annual.cash_flow, "increase_decrease_in_operating_capital")
assert v == -1_266_000_000
assert "imputed-rollup" in s
# Should be sum of mapped WC children
ar, _ = _val(blk_annual.cash_flow, "change_in_accounts_receivable")
ap, _ = _val(blk_annual.cash_flow, "change_in_accounts_payable")
other_wc, _ = _val(
blk_annual.cash_flow, "change_in_other_operating_assets_and_liabilities"
)
assert ar + ap + other_wc == v
# --- Plug rows ---
def test_operating_plug(self, blk_annual):
v, s = _val(blk_annual.cash_flow, "other_operating_activities")
assert "imputed-plug" in s
def test_investing_plug(self, blk_annual):
v, s = _val(blk_annual.cash_flow, "other_investing_activities_net")
assert "imputed-plug" in s
def test_financing_plug(self, blk_annual):
v, s = _val(blk_annual.cash_flow, "other_financing_activities_net")
assert "imputed-plug" in s
class TestBLKCrossStatement:
"""Cross-statement consistency checks."""
def test_cf_net_income_equals_is_net_income(self, blk_annual):
"""Net income in CF must match net income in IS (identity lock)."""
cf_ni, _ = _val(blk_annual.cash_flow, "net_income")
is_ni, _ = _val(blk_annual.income_statement, "net_income")
assert cf_ni == is_ni
def test_cross_statement_consistency_all_dates(self, blk_annual):
"""CF net_income = IS net_income for every date."""
is_ni = {
r["period_ending"]: r["value"]
for r in blk_annual.income_statement
if r["tag"] == "net_income"
}
cf_ni = {
r["period_ending"]: r["value"]
for r in blk_annual.cash_flow
if r["tag"] == "net_income"
}
for date, value in is_ni.items():
assert date in cf_ni, f"CF missing net_income for {date}"
assert value == cf_ni[date], f"NI mismatch at {date}"
def test_bs_identity_all_dates(self, blk_annual):
"""Assets = L&E for every date."""
dates = sorted(set(r["period_ending"] for r in blk_annual.balance_sheet))
for date in dates:
ta, _ = _val(blk_annual.balance_sheet, "total_assets", date)
tle, _ = _val(
blk_annual.balance_sheet, "total_liabilities_and_equity", date
)
assert ta == tle, f"A≠L&E at {date}: {ta} vs {tle}"
def test_cf_identity_all_dates(self, blk_annual):
"""op + inv + fin + fx = net_change for every date."""
dates = sorted(set(r["period_ending"] for r in blk_annual.cash_flow))
for date in dates:
op, _ = _val(
blk_annual.cash_flow, "net_cash_from_operating_activities", date
)
inv, _ = _val(
blk_annual.cash_flow, "net_cash_from_investing_activities", date
)
fin, _ = _val(
blk_annual.cash_flow, "net_cash_from_financing_activities", date
)
fx, _ = _val(blk_annual.cash_flow, "effect_of_exchange_rate_changes", date)
nc, _ = _val(blk_annual.cash_flow, "net_change_in_cash", date)
if all(x is not None for x in (op, inv, fin, fx, nc)):
assert op + inv + fin + fx == nc, f"CF identity fails at {date}"
class TestBLKImputationCounts:
"""Verify the expected number and types of imputed values."""
def test_is_imputed_count(self, blk_annual):
imputed = [
r
for r in blk_annual.income_statement
if r["period_ending"] == "2025-12-31" and "imputed" in r["source"]
]
assert len(imputed) == 4
def test_bs_imputed_count(self, blk_annual):
imputed = [
r
for r in blk_annual.balance_sheet
if r["period_ending"] == "2025-12-31" and "imputed" in r["source"]
]
assert len(imputed) == 4
def test_cf_imputed_count(self, blk_annual):
imputed = [
r
for r in blk_annual.cash_flow
if r["period_ending"] == "2025-12-31" and "imputed" in r["source"]
]
assert len(imputed) == 4
def test_no_suspect_zeros(self, blk_annual):
"""No imputed values should be suspect zeros for BLK."""
all_records = (
blk_annual.income_statement
+ blk_annual.balance_sheet
+ blk_annual.cash_flow
)
suspect = [r for r in all_records if "imputed-zero" in r.get("source", "")]
assert len(suspect) == 0
class TestBLKMultiYear:
"""Verify consistent extraction across all three years."""
@pytest.mark.parametrize("date", ["2023-12-31", "2024-12-31", "2025-12-31"])
def test_revenue_positive_all_years(self, blk_annual, date):
v, _ = _val(blk_annual.income_statement, "total_revenue", date)
assert v is not None and v > 0
@pytest.mark.parametrize("date", ["2023-12-31", "2024-12-31", "2025-12-31"])
def test_assets_positive_all_years(self, blk_annual, date):
v, _ = _val(blk_annual.balance_sheet, "total_assets", date)
assert v is not None and v > 0
def test_tag_coverage_stable(self, blk_annual):
"""Each date should have similar tag coverage (within reason)."""
dates = sorted(set(r["period_ending"] for r in blk_annual.income_statement))
counts = {
d: len([r for r in blk_annual.income_statement if r["period_ending"] == d])
for d in dates
}
max_count = max(counts.values())
min_count = min(counts.values())
# Tag count shouldn't vary widely between years
assert max_count - min_count <= 10, f"Tag count varies too much: {counts}"
def _anchor(year, extra=None):
"""Minimal tags to anchor a year (BS instant + CF duration)."""
base = [
{"tag": "Assets", "val": 1000, "end": f"{year}-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
},
]
if extra:
base.extend(extra)
return base
def _pick(rows, tag, date):
"""Return the single record matching a standardized tag and period end."""
matches = [r for r in rows if r["tag"] == tag and r["period_ending"] == date]
assert len(matches) == 1, f"expected one {tag} @ {date}, got {len(matches)}"
return matches[0]
class TestFinancialIS:
"""Financial institution revenue decomposition (banks)."""
def test_revenue_from_nii_plus_noninterest(self):
"""total_revenue = net_interest_income + total_noninterest_income."""
mock = create_mock_facts(
_anchor(2023)
+ [
{
"tag": "InterestIncomeExpenseNet",
"val": 50_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "NoninterestIncome",
"val": 30_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeLossFromContinuingOperationsBeforeIncomeTaxes"
"ExtraordinaryItemsNoncontrollingInterest",
"val": 60_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeTaxExpenseBenefit",
"val": 15_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
assert res.company_type == "financial"
rev = [
r
for r in res.income_statement
if r["tag"] == "total_revenue" and r["period_ending"] == "2023-12-31"
]
assert len(rev) == 1
assert rev[0]["value"] == 80_000 # 50k NII + 30k noninterest
assert "imputed" in rev[0]["source"]
def test_provision_for_credit_losses(self):
"""net_interest_income_after_provision = NII - provision."""
mock = create_mock_facts(
_anchor(2023)
+ [
{
"tag": "InterestIncomeExpenseNet",
"val": 60_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "NoninterestIncome",
"val": 20_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "ProvisionForLoanAndLeaseLosses",
"val": 5_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeLossFromContinuingOperationsBeforeIncomeTaxes"
"ExtraordinaryItemsNoncontrollingInterest",
"val": 50_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeTaxExpenseBenefit",
"val": 10_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
niiap = [
r
for r in res.income_statement
if r["tag"] == "net_interest_income_after_provision"
and r["period_ending"] == "2023-12-31"
]
assert len(niiap) == 1
assert niiap[0]["value"] == 55_000 # 60k - 5k
class TestProvisionTagMigration:
"""Modern provision-for-credit-losses tag chains (IFRS + US-GAAP CECL)."""
_NII = {
"tag": "InterestIncomeExpenseNet",
"val": 60_000,
"start": "2023-01-01",
"end": "2023-12-31",
}
_NONINT = {
"tag": "NoninterestIncome",
"val": 20_000,
"start": "2023-01-01",
"end": "2023-12-31",
}
_PRETAX = {
"tag": "IncomeLossFromContinuingOperationsBeforeIncomeTaxes"
"ExtraordinaryItemsNoncontrollingInterest",
"val": 50_000,
"start": "2023-01-01",
"end": "2023-12-31",
}
_TAX = {
"tag": "IncomeTaxExpenseBenefit",
"val": 10_000,
"start": "2023-01-01",
"end": "2023-12-31",
}
def test_ifrs_adjustments_for_impairment_captures_provision(self):
"""IFRS bank tag AdjustmentsForImpairmentLoss... yields a positive provision."""
mock = create_mock_facts(
_anchor(2023)
+ [
self._NII,
self._NONINT,
{
"ns": "ifrs-full",
"tag": "AdjustmentsForImpairmentLossReversalOf"
"ImpairmentLossRecognisedInProfitOrLoss",
"val": 4_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
self._PRETAX,
self._TAX,
]
)
res = resolve_company_facts(mock, period="annual")
assert res.company_type == "financial"
prov = _pick(res.income_statement, "provision_for_credit_losses", "2023-12-31")
assert prov["value"] == 4_000
assert "AdjustmentsForImpairmentLoss" in prov["source"]
niiap = _pick(
res.income_statement,
"net_interest_income_after_provision",
"2023-12-31",
)
assert niiap["value"] == 56_000 # 60k NII - 4k provision
def test_ifrs_adjustments_for_provisions_wins_over_impairment(self):
"""BMO pattern: AdjustmentsForProvisions is preferred over the impairment tag."""
mock = create_mock_facts(
_anchor(2023)
+ [
self._NII,
self._NONINT,
{
"ns": "ifrs-full",
"tag": "AdjustmentsForProvisions",
"val": 3_700,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"ns": "ifrs-full",
"tag": "AdjustmentsForImpairmentLossReversalOf"
"ImpairmentLossRecognisedInProfitOrLoss",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
self._PRETAX,
self._TAX,
]
)
res = resolve_company_facts(mock, period="annual")
prov = _pick(res.income_statement, "provision_for_credit_losses", "2023-12-31")
assert prov["value"] == 3_700
assert "AdjustmentsForProvisions" in prov["source"]
def test_cecl_funded_plus_unfunded_are_combined(self):
"""BAC pattern: provision = funded + unfunded when no NII-after-provision tag."""
mock = create_mock_facts(
_anchor(2023)
+ [
self._NII,
self._NONINT,
{
"tag": "FinancingReceivableExcludingAccruedInterest"
"CreditLossExpenseReversal",
"val": 5_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "OffBalanceSheetCreditLossLiability"
"CreditLossExpenseReversal",
"val": -300,
"start": "2023-01-01",
"end": "2023-12-31",
},
self._PRETAX,
self._TAX,
]
)
res = resolve_company_facts(mock, period="annual")
prov = _pick(res.income_statement, "provision_for_credit_losses", "2023-12-31")
assert prov["value"] == 4_700 # 5000 funded + (-300) unfunded
assert "(combined)" in prov["source"]
cf_prov = _pick(res.cash_flow, "provision_for_loan_losses", "2023-12-31")
assert cf_prov["value"] == 4_700
def test_cecl_not_combined_when_nii_after_provision_reported(self):
"""Citi pattern: funded provision left alone when NII-after-provision is tagged."""
mock = create_mock_facts(
_anchor(2023)
+ [
self._NII,
self._NONINT,
{
"tag": "ProvisionForLoanLossesExpensed",
"val": 5_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "OffBalanceSheetCreditLossLiability"
"CreditLossExpenseReversal",
"val": -300,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "InterestIncomeExpenseAfterProvisionForLoanLoss",
"val": 55_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
self._PRETAX,
self._TAX,
]
)
res = resolve_company_facts(mock, period="annual")
prov = _pick(res.income_statement, "provision_for_credit_losses", "2023-12-31")
assert prov["value"] == 5_000 # funded only; unfunded NOT added
assert "(combined)" not in prov["source"]
class TestBankInsuranceClassification:
"""Tie-break between the insurance and financial company-type templates."""
def test_bank_with_insurance_subsidiary_is_financial(self, schema):
"""More core-banking signals than insurance IS signals resolves to financial."""
facts = create_mock_facts(
[
{"tag": "InsuranceRevenue", "val": 1, "end": "2024-12-31"},
{"tag": "NetEarnedPremium", "val": 1, "end": "2024-12-31"},
{
"tag": "InsuranceContractsThatAreLiabilities",
"val": 1,
"end": "2024-12-31",
},
{"tag": "InterestIncomeExpenseNet", "val": 1, "end": "2024-12-31"},
{"tag": "NetInterestIncome", "val": 1, "end": "2024-12-31"},
{"tag": "TradingIncomeExpense", "val": 1, "end": "2024-12-31"},
]
)["facts"]
# ins_is=2, ins_total=3, fin=3 -> financial
assert schema.detect_type(facts) == "financial"
def test_genuine_insurer_stays_insurance(self, schema):
"""Insurance IS signals outnumbering financial signals stays insurance."""
facts = create_mock_facts(
[
{"tag": "PremiumsEarnedNet", "val": 1, "end": "2024-12-31"},
{
"tag": "PolicyholderBenefitsAndClaimsIncurredNet",
"val": 1,
"end": "2024-12-31",
},
{"tag": "InsuranceRevenue", "val": 1, "end": "2024-12-31"},
{
"tag": "LiabilityForFuturePolicyBenefits",
"val": 1,
"end": "2024-12-31",
},
{"tag": "InterestIncomeExpenseNet", "val": 1, "end": "2024-12-31"},
{"tag": "TradingIncomeExpense", "val": 1, "end": "2024-12-31"},
]
)["facts"]
# ins_is=3, fin=2 -> insurance
assert schema.detect_type(facts) == "insurance"
class TestInsuranceIS:
"""Insurance company revenue/benefit structure."""
def test_insurance_detection_and_pretax(self):
"""total_pretax_income = total_revenue - benefits_costs_expenses."""
mock = create_mock_facts(
_anchor(2023)
+ [
{
"tag": "PremiumsEarnedNet",
"val": 100_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "Revenues",
"val": 120_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "BenefitsLossesAndExpenses",
"val": 80_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "LiabilityForFuturePolicyBenefits",
"val": 200_000,
"end": "2023-12-31",
},
{
"tag": "IncomeTaxExpenseBenefit",
"val": 8_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
assert res.company_type == "insurance"
pretax = [
r
for r in res.income_statement
if r["tag"] == "total_pretax_income" and r["period_ending"] == "2023-12-31"
]
assert len(pretax) == 1
assert pretax[0]["value"] == 40_000 # 120k rev - 80k benefits
def test_insurance_net_income_cascade(self):
"""net_income_continuing = total_pretax_income - income_tax_expense."""
mock = create_mock_facts(
_anchor(2023)
+ [
{
"tag": "PremiumsEarnedNet",
"val": 100_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "Revenues",
"val": 120_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "BenefitsLossesAndExpenses",
"val": 80_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "LiabilityForFuturePolicyBenefits",
"val": 200_000,
"end": "2023-12-31",
},
{
"tag": "IncomeTaxExpenseBenefit",
"val": 8_000,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
nic = [
r
for r in res.income_statement
if r["tag"] == "net_income_continuing"
and r["period_ending"] == "2023-12-31"
]
assert len(nic) == 1
assert nic[0]["value"] == 32_000 # 40k pretax - 8k tax
class TestDiscontinuedOperations:
"""Discontinued operations impact on net income."""
def test_discontinued_ops_adds_to_net_income(self):
"""net_income = net_income_continuing + net_income_discontinued."""
mock = create_mock_facts(
_anchor(2023)
+ [
{
"tag": "Revenues",
"val": 1000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "CostOfGoodsAndServicesSold",
"val": 400,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeLossFromContinuingOperationsBeforeIncomeTaxes"
"ExtraordinaryItemsNoncontrollingInterest",
"val": 500,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeTaxExpenseBenefit",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
# Explicit continuing-ops tag avoids ProfitLoss fallback
"tag": "IncomeLossFromContinuingOperations",
"val": 400,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeLossFromDiscontinuedOperationsNetOfTax",
"val": 50,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "ProfitLoss",
"val": 450,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
def get_val(tag):
return next(
(
r["value"]
for r in res.income_statement
if r["tag"] == tag and r["period_ending"] == "2023-12-31"
),
None,
)
assert get_val("net_income_continuing") == 400 # explicit continuing tag
assert get_val("net_income_discontinued") == 50
assert get_val("net_income") == 450 # ProfitLoss
assert get_val("net_income_continuing") + get_val(
"net_income_discontinued"
) == get_val("net_income")
class TestPreferredEquity:
"""Preferred stock decomposition in BS equity."""
def test_common_equity_with_preferred(self):
"""total_common_equity = total_equity - total_preferred_equity."""
mock = create_mock_facts(
[
{"tag": "Assets", "val": 10000, "end": "2023-12-31"},
{
"tag": "LiabilitiesAndStockholdersEquity",
"val": 10000,
"end": "2023-12-31",
},
{"tag": "Liabilities", "val": 7000, "end": "2023-12-31"},
{"tag": "StockholdersEquity", "val": 2800, "end": "2023-12-31"},
{
"tag": "StockholdersEquityIncludingPortionAttributableTo"
"NoncontrollingInterest",
"val": 3000,
"end": "2023-12-31",
},
{"tag": "MinorityInterest", "val": 200, "end": "2023-12-31"},
{"tag": "PreferredStockValue", "val": 500, "end": "2023-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
# IS duration tag required for date alignment across all 3 statements
{
"tag": "Revenues",
"val": 500,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
def bs_val(tag):
return next(
(
r["value"]
for r in res.balance_sheet
if r["tag"] == tag and r["period_ending"] == "2023-12-31"
),
None,
)
assert bs_val("total_equity") == 2800
assert bs_val("total_preferred_equity") == 500
# common = total_equity - preferred
tce = bs_val("total_common_equity")
assert tce == 2300
def test_common_equity_without_preferred(self):
"""Without preferred, total_common_equity == total_equity."""
mock = create_mock_facts(
[
{"tag": "Assets", "val": 5000, "end": "2023-12-31"},
{
"tag": "LiabilitiesAndStockholdersEquity",
"val": 5000,
"end": "2023-12-31",
},
{"tag": "Liabilities", "val": 3000, "end": "2023-12-31"},
{"tag": "StockholdersEquity", "val": 2000, "end": "2023-12-31"},
{
"tag": "StockholdersEquityIncludingPortionAttributableTo"
"NoncontrollingInterest",
"val": 2000,
"end": "2023-12-31",
},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 50,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "Revenues",
"val": 300,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
def bs_val(tag):
return next(
(
r["value"]
for r in res.balance_sheet
if r["tag"] == tag and r["period_ending"] == "2023-12-31"
),
None,
)
tce = bs_val("total_common_equity")
assert tce == 2000
class TestMergeFacts:
"""merge_facts() combines multiple CIK JSON dicts."""
def test_basic_merge(self, schema):
"""Two CIKs with different years produce combined history."""
old_cik = create_mock_facts(
[
{
"tag": "Revenues",
"val": 1000,
"start": "2020-01-01",
"end": "2020-12-31",
"filed": "2021-03-01",
},
]
)
new_cik = create_mock_facts(
[
{
"tag": "Revenues",
"val": 1500,
"start": "2023-01-01",
"end": "2023-12-31",
"filed": "2024-03-01",
},
]
)
merged = StatementSchema.merge_facts(old_cik, new_cik)
revenue_entries = merged["us-gaap"]["Revenues"]["units"]["USD"]
assert len(revenue_entries) == 2
vals = {e["val"] for e in revenue_entries}
assert vals == {1000, 1500}
def test_deduplication(self, schema):
"""Identical entries across CIKs are not duplicated."""
facts_a = create_mock_facts(
[
{
"tag": "Assets",
"val": 5000,
"end": "2023-12-31",
"filed": "2024-03-01",
},
]
)
facts_b = create_mock_facts(
[
{
"tag": "Assets",
"val": 5000,
"end": "2023-12-31",
"filed": "2024-03-01",
},
]
)
merged = StatementSchema.merge_facts(facts_a, facts_b)
entries = merged["us-gaap"]["Assets"]["units"]["USD"]
assert len(entries) == 1
def test_merge_preserves_namespaces(self, schema):
"""Merge combines tags across different namespaces."""
us_facts = create_mock_facts(
[{"tag": "Revenues", "val": 100, "end": "2023-12-31"}]
)
ifrs_facts = create_mock_facts(
[{"ns": "ifrs-full", "tag": "Revenue", "val": 200, "end": "2023-12-31"}]
)
merged = StatementSchema.merge_facts(us_facts, ifrs_facts)
assert "us-gaap" in merged
assert "ifrs-full" in merged
def test_merge_different_values_same_tag_different_dates(self, schema):
"""Same tag reported with different values on different dates retained."""
a = create_mock_facts(
[
{
"tag": "Assets",
"val": 1000,
"end": "2022-12-31",
"filed": "2023-03-01",
},
]
)
b = create_mock_facts(
[
{
"tag": "Assets",
"val": 2000,
"end": "2023-12-31",
"filed": "2024-03-01",
},
]
)
merged = StatementSchema.merge_facts(a, b)
entries = merged["us-gaap"]["Assets"]["units"]["USD"]
assert len(entries) == 2
class TestTagChainPriority:
"""First matching XBRL tag in the chain wins."""
def test_newer_revenue_tag_preferred(self):
"""RevenueFromContractWithCustomer... is first in operating_revenue chain.
operating_revenue and total_revenue are separate schema rows with
independent tag chains. RFCWCE is position #1 for operating_revenue
but lower priority for total_revenue (where Revenues is preferred).
"""
mock = create_mock_facts(
_anchor(2023)
+ [
{
"tag": "RevenueFromContractWithCustomerExcludingAssessedTax",
"val": 1000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "Revenues",
"val": 1100,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "CostOfGoodsAndServicesSold",
"val": 400,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
# operating_revenue uses RFCWCE as first-priority tag
orev = [
r
for r in res.income_statement
if r["tag"] == "operating_revenue" and r["period_ending"] == "2023-12-31"
]
assert orev[0]["value"] == 1000
assert (
"RevenueFromContractWithCustomerExcludingAssessedTax" in orev[0]["source"]
)
# total_revenue picks Revenues (higher in its own chain)
trev = [
r
for r in res.income_statement
if r["tag"] == "total_revenue" and r["period_ending"] == "2023-12-31"
]
assert trev[0]["value"] == 1100
assert "Revenues" in trev[0]["source"]
def test_fallback_to_secondary_tag(self):
"""When primary tag absent, secondary tag is used."""
mock = create_mock_facts(
_anchor(2023)
+ [
{
"tag": "Revenues",
"val": 900,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "CostOfGoodsAndServicesSold",
"val": 300,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
rev = [
r
for r in res.income_statement
if r["tag"] == "total_revenue" and r["period_ending"] == "2023-12-31"
]
assert rev[0]["value"] == 900
assert "Revenues" in rev[0]["source"]
class TestCFFxDerivation:
"""The exchange-rate effect is never derived from the cash identity."""
def test_fx_not_derived_from_cash_identity(self):
"""An unexplained net change is held in other_net_changes_in_cash."""
m = 1_000_000
mock = create_mock_facts(
[
{"tag": "Assets", "val": 1000 * m, "end": "2023-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 500 * m,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "NetCashProvidedByUsedInInvestingActivities",
"val": -200 * m,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "NetCashProvidedByUsedInFinancingActivities",
"val": -150 * m,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents"
"PeriodIncreaseDecreaseIncludingExchangeRateEffect",
"val": 160 * m,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "Revenues",
"val": 800 * m,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
by_tag = {
r["tag"]: r for r in res.cash_flow if r["period_ending"] == "2023-12-31"
}
assert "effect_of_exchange_rate_changes" not in by_tag
other = by_tag["other_net_changes_in_cash"]
assert other["value"] == 10 * m
assert other["source"].startswith("imputed-plug: net_change_in_cash - ")
class TestCFDepreciationDecomposition:
"""D&A = depreciation + amortization when only components given."""
def test_da_from_components(self):
mock = create_mock_facts(
[
{"tag": "Assets", "val": 1000, "end": "2023-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "Depreciation",
"val": 60,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "AmortizationOfIntangibleAssets",
"val": 40,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
da = [
r
for r in res.cash_flow
if r["tag"] == "depreciation_and_amortization"
and r["period_ending"] == "2023-12-31"
]
assert len(da) == 1
assert da[0]["value"] == 100 # 60 + 40
class TestBSNoncurrentDerivation:
"""Noncurrent = total - current decomposition."""
def test_noncurrent_assets_derived(self):
mock = create_mock_facts(
[
{"tag": "Assets", "val": 10000, "end": "2023-12-31"},
{
"tag": "LiabilitiesAndStockholdersEquity",
"val": 10000,
"end": "2023-12-31",
},
{"tag": "AssetsCurrent", "val": 3000, "end": "2023-12-31"},
{"tag": "Liabilities", "val": 6000, "end": "2023-12-31"},
{
"tag": "LiabilitiesCurrent",
"val": 2000,
"end": "2023-12-31",
},
{"tag": "StockholdersEquity", "val": 4000, "end": "2023-12-31"},
{
"tag": "StockholdersEquityIncludingPortionAttributableTo"
"NoncontrollingInterest",
"val": 4000,
"end": "2023-12-31",
},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "Revenues",
"val": 600,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
def bs_val(tag):
return next(
(
r["value"]
for r in res.balance_sheet
if r["tag"] == tag and r["period_ending"] == "2023-12-31"
),
None,
)
assert bs_val("total_noncurrent_assets") == 7000 # 10000 - 3000
assert bs_val("total_noncurrent_liabilities") == 4000 # 6000 - 2000
class TestBSLiabilitiesImputed:
"""Total liabilities imputed from L&E identity."""
def test_liabilities_from_identity(self):
"""total_liabilities = L&E - total_equity_and_nci."""
mock = create_mock_facts(
[
{"tag": "Assets", "val": 10000, "end": "2023-12-31"},
# L&E must be extracted directly (not from rollup)
{
"tag": "LiabilitiesAndStockholdersEquity",
"val": 10000,
"end": "2023-12-31",
},
{"tag": "StockholdersEquity", "val": 3000, "end": "2023-12-31"},
{
"tag": "StockholdersEquityIncludingPortionAttributableTo"
"NoncontrollingInterest",
"val": 3500,
"end": "2023-12-31",
},
{"tag": "MinorityInterest", "val": 500, "end": "2023-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "Revenues",
"val": 400,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
def bs_val(tag):
return next(
(
r["value"]
for r in res.balance_sheet
if r["tag"] == tag and r["period_ending"] == "2023-12-31"
),
None,
)
# L = L&E - E_nci = 10000 - 3500 = 6500
assert bs_val("total_liabilities") == 6500
assert bs_val("total_assets") == 10000
assert bs_val("total_liabilities_and_equity") == 10000
class TestDiversifiedISCascade:
"""Diversified IS imputation: operating income without COGS/GP."""
def test_diversified_cascade_with_operating_income(self):
"""Diversified IS cascade: OI → pretax → NI works correctly."""
mock = create_mock_facts(
_anchor(2023)
+ [
{
"tag": "RevenueFromContractWithCustomerExcludingAssessedTax",
"val": 5000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "CostsAndExpenses",
"val": 3500,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
# OperatingIncomeLoss extracted directly avoids rollup
"tag": "OperatingIncomeLoss",
"val": 1500,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "NonoperatingIncomeExpense",
"val": 200,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeTaxExpenseBenefit",
"val": 300,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
assert res.company_type == "diversified"
def get_val(tag):
return next(
(
r["value"]
for r in res.income_statement
if r["tag"] == tag and r["period_ending"] == "2023-12-31"
),
None,
)
assert get_val("total_revenue") == 5000
assert get_val("costs_and_expenses") == 3500
assert get_val("total_operating_income") == 1500
assert get_val("total_other_income") == 200
# pretax = OI + other_income = 1700
assert get_val("total_pretax_income") == 1700
assert get_val("net_income_continuing") == 1400 # 1700 - 300
class TestNoncurrentAssetsOnly:
"""Current/noncurrent: when only total_current_assets present, noncurrent derived."""
def test_equity_nci_fallback(self):
"""When no NCI, total_equity_and_noncontrolling_interests = total_equity."""
mock = create_mock_facts(
[
{"tag": "Assets", "val": 5000, "end": "2023-12-31"},
{"tag": "Liabilities", "val": 3000, "end": "2023-12-31"},
{"tag": "StockholdersEquity", "val": 2000, "end": "2023-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "Revenues",
"val": 350,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
def bs_val(tag):
return next(
(
r["value"]
for r in res.balance_sheet
if r["tag"] == tag and r["period_ending"] == "2023-12-31"
),
None,
)
# Fallback: E_nci = E when no NCI present
assert bs_val("total_equity") == 2000
assert bs_val("total_equity_and_noncontrolling_interests") == 2000
# L&E = Assets
assert bs_val("total_liabilities_and_equity") == 5000
# Identity: no diagnostics
assert len(res.diagnostics) == 0
class TestCOGSDisambiguation:
"""Narrow COGS override when COGS + OpEx << CostsAndExpenses."""
def test_narrow_cogs_corrected(self):
"""When narrow COGS reported, it's overridden to C&E - OpEx."""
mock = create_mock_facts(
_anchor(2023)
+ [
{
"tag": "Revenues",
"val": 10000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
# Narrow COGS (product-only): 2000
"tag": "CostOfGoodsAndServicesSold",
"val": 2000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
# But CostsAndExpenses is much larger: 8000
"tag": "CostsAndExpenses",
"val": 8000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "OperatingExpenses",
"val": 3000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "OperatingIncomeLoss",
"val": 2000,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeLossFromContinuingOperationsBeforeIncomeTaxes"
"ExtraordinaryItemsNoncontrollingInterest",
"val": 2200,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeTaxExpenseBenefit",
"val": 400,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
def get_val(tag):
matches = [
r
for r in res.income_statement
if r["tag"] == tag and r["period_ending"] == "2023-12-31"
]
return matches[0] if matches else None
# COGS should be corrected to C&E - OpEx = 8000 - 3000 = 5000
cogs = get_val("total_cost_of_revenue")
if cogs and "corrected" in cogs["source"]:
assert cogs["value"] == 5000
else:
# If not corrected, gross profit still articulates
gp = get_val("total_gross_profit")
assert gp is not None
# Operating income should still be 2000 regardless
oi = get_val("total_operating_income")
assert oi["value"] == 2000
class TestIdentityLockCrossStatement:
"""Identity lock: CF net_income is forced to match IS net_income."""
def test_cf_net_income_overridden_by_is(self):
"""CF ProfitLoss with identity_lock uses IS net_income value."""
mock = create_mock_facts(
[
{"tag": "Assets", "val": 1000, "end": "2023-12-31"},
{
"tag": "Revenues",
"val": 500,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "CostOfGoodsAndServicesSold",
"val": 200,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeLossFromContinuingOperationsBeforeIncomeTaxes"
"ExtraordinaryItemsNoncontrollingInterest",
"val": 250,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "IncomeTaxExpenseBenefit",
"val": 50,
"start": "2023-01-01",
"end": "2023-12-31",
},
# This ProfitLoss appears in both IS and CF
{
"tag": "ProfitLoss",
"val": 200,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 300,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
is_ni = next(
(
r["value"]
for r in res.income_statement
if r["tag"] == "net_income" and r["period_ending"] == "2023-12-31"
),
None,
)
cf_ni = next(
(
r["value"]
for r in res.cash_flow
if r["tag"] == "net_income" and r["period_ending"] == "2023-12-31"
),
None,
)
# Both should be the same regardless of source
if is_ni is not None and cf_ni is not None:
assert is_ni == cf_ni
class TestMultiDateDiagnostics:
"""Diagnostics capture failures at specific dates."""
def test_bs_identity_violation_flagged(self):
"""When Assets != L&E by > 1M, a diagnostic is produced.
The verification tolerance is $1M, so values must be large enough
that the mismatch exceeds the threshold.
"""
mock = create_mock_facts(
[
{"tag": "Assets", "val": 1_000_000_000, "end": "2023-12-31"},
{
"tag": "LiabilitiesAndStockholdersEquity",
"val": 900_000_000,
"end": "2023-12-31",
},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
{
"tag": "Revenues",
"val": 500,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
# Should see diagnostic for total_assets mismatch
asset_diags = [d for d in res.diagnostics if d.tag == "total_assets"]
assert len(asset_diags) >= 1
assert asset_diags[0].date == "2023-12-31"
assert asset_diags[0].actual == 1_000_000_000
assert asset_diags[0].expected == 900_000_000
class TestCashBridgePeriodCarryover:
"""cash_at_beginning_of_period derived from prior year end."""
def test_begin_derived_from_prior_end(self):
"""Begin-of-period cash = end-of-period cash from prior year."""
mock = create_mock_facts(
_anchor(2022)
+ _anchor(2023)
+ [
{
"tag": "CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents",
"val": 500,
"end": "2022-12-31",
},
{
"tag": "CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents",
"val": 600,
"end": "2023-12-31",
},
{
"tag": "CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents"
"PeriodIncreaseDecreaseIncludingExchangeRateEffect",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
begin = [
r
for r in res.cash_flow
if r["tag"] == "cash_at_beginning_of_period"
and r["period_ending"] == "2023-12-31"
]
if begin:
# Begin 2023 should be end 2022
assert begin[0]["value"] == 500
assert "derived" in begin[0]["source"]
class TestTTM:
"""Tests for trailing-twelve-month (TTM) aggregation."""
@staticmethod
def _quarterly_revenue(years_quarters):
entries = []
anchor_entries = []
for year, quarter, val in years_quarters:
q_map = {"Q1": "03-31", "Q2": "06-30", "Q3": "09-30", "Q4": "12-31"}
q_start_map = {"Q1": "01-01", "Q2": "04-01", "Q3": "07-01", "Q4": "10-01"}
end = f"{year}-{q_map[quarter]}"
start = f"{year}-{q_start_map[quarter]}"
entries.append(
{
"tag": "Revenues",
"val": val,
"start": start,
"end": end,
"form": "10-Q" if quarter != "Q4" else "10-K",
"fp": quarter if quarter != "Q4" else "FY",
"fy": year,
}
)
anchor_entries.append(
{
"tag": "Assets",
"val": 1000,
"end": end,
}
)
anchor_entries.append(
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 50,
"start": start,
"end": end,
"form": "10-Q" if quarter != "Q4" else "10-K",
"fp": quarter if quarter != "Q4" else "FY",
"fy": year,
}
)
for year in {y for y, _, _ in years_quarters}:
total = sum(v for y, q, v in years_quarters if y == year)
entries.append(
{
"tag": "Revenues",
"val": total,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
"form": "10-K",
"fp": "FY",
"fy": year,
}
)
anchor_entries.append(
{
"tag": "Assets",
"val": 1000,
"end": f"{year}-12-31",
}
)
anchor_entries.append(
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 200,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
"form": "10-K",
"fp": "FY",
"fy": year,
}
)
return create_mock_facts(entries + anchor_entries)
def test_ttm_sum_for_duration_items(self):
mock = self._quarterly_revenue(
[
(2023, "Q1", 100),
(2023, "Q2", 110),
(2023, "Q3", 120),
(2023, "Q4", 130),
]
)
res = resolve_company_facts(mock, period="ttm")
ttm_recs = [r for r in res.income_statement if r["tag"] == "total_revenue"]
assert len(ttm_recs) >= 1
latest = max(ttm_recs, key=lambda r: r["period_ending"])
assert latest["value"] == 460
assert latest["fiscal_period"] == "TTM"
assert latest["frequency"] == "ttm"
assert "TTM: sum(" in latest["source"]
def test_ttm_fewer_than_4_quarters_skipped(self):
entries = []
for q, end_md, start_md, val in [
("Q1", "03-31", "01-01", 100),
("Q2", "06-30", "04-01", 110),
]:
entries.append(
{
"tag": "Revenues",
"val": val,
"start": f"2023-{start_md}",
"end": f"2023-{end_md}",
"form": "10-Q",
"fp": q,
"fy": 2023,
}
)
entries.append(
{
"tag": "Assets",
"val": 1000,
"end": f"2023-{end_md}",
}
)
entries.append(
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 50,
"start": f"2023-{start_md}",
"end": f"2023-{end_md}",
"form": "10-Q",
"fp": q,
"fy": 2023,
}
)
mock = create_mock_facts(entries)
res = resolve_company_facts(mock, period="ttm")
ttm_recs = [r for r in res.income_statement if r["tag"] == "total_revenue"]
assert len(ttm_recs) == 0
def test_ttm_avg_for_instant_items(self):
entries = []
for q, end in [
("Q1", "2023-03-31"),
("Q2", "2023-06-30"),
("Q3", "2023-09-30"),
("Q4", "2023-12-31"),
]:
entries.append(
{
"tag": "Assets",
"val": 1000 + int(q[1]) * 100,
"end": end,
"form": "10-Q" if q != "Q4" else "10-K",
"fp": q if q != "Q4" else "FY",
"fy": 2023,
}
)
entries.append(
{
"tag": "Assets",
"val": 1300,
"end": "2023-12-31",
"form": "10-K",
"fp": "FY",
"fy": 2023,
}
)
entries.append(
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
"form": "10-K",
"fp": "FY",
"fy": 2023,
}
)
for q, end in [
("Q1", "2023-03-31"),
("Q2", "2023-06-30"),
("Q3", "2023-09-30"),
("Q4", "2023-12-31"),
]:
start = {
"Q1": "2023-01-01",
"Q2": "2023-04-01",
"Q3": "2023-07-01",
"Q4": "2023-10-01",
}[q]
entries.append(
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 25,
"start": start,
"end": end,
"form": "10-Q" if q != "Q4" else "10-K",
"fp": q if q != "Q4" else "FY",
"fy": 2023,
}
)
mock = create_mock_facts(entries)
res = resolve_company_facts(mock, period="ttm")
asset_recs = [r for r in res.balance_sheet if r["tag"] == "total_assets"]
if asset_recs:
latest = max(asset_recs, key=lambda r: r["period_ending"])
assert "TTM: avg(" in latest["source"]
assert latest["value"] == (1100 + 1200 + 1300 + 1400) / 4
def test_ttm_provenance_string(self):
mock = self._quarterly_revenue(
[
(2023, "Q1", 100),
(2023, "Q2", 110),
(2023, "Q3", 120),
(2023, "Q4", 130),
]
)
res = resolve_company_facts(mock, period="ttm")
ttm_recs = [r for r in res.income_statement if r["tag"] == "total_revenue"]
if ttm_recs:
src = ttm_recs[0]["source"]
assert src.startswith("TTM:")
assert "2023-" in src
def test_ttm_rolling_windows(self):
mock = self._quarterly_revenue(
[
(2023, "Q1", 100),
(2023, "Q2", 110),
(2023, "Q3", 120),
(2023, "Q4", 130),
(2024, "Q1", 140),
]
)
res = resolve_company_facts(mock, period="ttm")
ttm_recs = sorted(
[r for r in res.income_statement if r["tag"] == "total_revenue"],
key=lambda r: r["period_ending"],
)
assert len(ttm_recs) >= 2
assert ttm_recs[0]["value"] == 460 # Q1-Q4 2023
assert ttm_recs[1]["value"] == 500 # Q2 2023 - Q1 2024
class TestPctChange:
"""Tests for period-over-period percent-change calculation."""
@staticmethod
def _annual_revenue(year_vals):
entries = []
for year, val in year_vals:
entries.append(
{
"tag": "Revenues",
"val": val,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
"form": "10-K",
"fp": "FY",
"fy": year,
}
)
entries.append(
{
"tag": "Assets",
"val": 1000,
"end": f"{year}-12-31",
}
)
entries.append(
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
"form": "10-K",
"fp": "FY",
"fy": year,
}
)
return create_mock_facts(entries)
def test_yoy_annual(self):
mock = self._annual_revenue([(2023, 1000), (2024, 1100)])
res = resolve_company_facts(mock, period="yoy")
yoy_recs = [r for r in res.income_statement if r["tag"] == "total_revenue"]
assert len(yoy_recs) >= 1
latest = max(yoy_recs, key=lambda r: r["period_ending"])
assert abs(latest["value"] - 0.1) < 0.001
assert latest["unit"] == "percent"
assert latest["currency"] == ""
assert "yoy:" in latest["source"]
def test_yoy_quarterly(self):
entries = []
for year in [2023, 2024]:
for q, end_md, start_md in [
("Q1", "03-31", "01-01"),
("Q2", "06-30", "04-01"),
]:
entries.append(
{
"tag": "Revenues",
"val": 100 * (year - 2022) + (10 if q == "Q2" else 0),
"start": f"{year}-{start_md}",
"end": f"{year}-{end_md}",
"form": "10-Q",
"fp": q,
"fy": year,
}
)
entries.append(
{
"tag": "Assets",
"val": 1000,
"end": f"{year}-{end_md}",
}
)
entries.append(
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 50,
"start": f"{year}-{start_md}",
"end": f"{year}-{end_md}",
"form": "10-Q",
"fp": q,
"fy": year,
}
)
total = sum(
100 * (year - 2022) + (10 if q == "Q2" else 0) for q in ["Q1", "Q2"]
)
entries.append(
{
"tag": "Revenues",
"val": total,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
"form": "10-K",
"fp": "FY",
"fy": year,
}
)
entries.append(
{
"tag": "Assets",
"val": 1000,
"end": f"{year}-12-31",
}
)
entries.append(
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
"form": "10-K",
"fp": "FY",
"fy": year,
}
)
mock = create_mock_facts(entries)
res = resolve_company_facts(mock, period="yoy_quarterly")
yoy_recs = [r for r in res.income_statement if r["tag"] == "total_revenue"]
assert len(yoy_recs) >= 1
for rec in yoy_recs:
assert rec["frequency"] == "yoy_quarterly"
assert rec["unit"] == "percent"
def test_pop(self):
entries = []
for q, end_md, start_md, val in [
("Q1", "03-31", "01-01", 100),
("Q2", "06-30", "04-01", 120),
]:
entries.append(
{
"tag": "Revenues",
"val": val,
"start": f"2023-{start_md}",
"end": f"2023-{end_md}",
"form": "10-Q",
"fp": q,
"fy": 2023,
}
)
entries.append(
{
"tag": "Assets",
"val": 1000,
"end": f"2023-{end_md}",
}
)
entries.append(
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 50,
"start": f"2023-{start_md}",
"end": f"2023-{end_md}",
"form": "10-Q",
"fp": q,
"fy": 2023,
}
)
entries.append(
{
"tag": "Revenues",
"val": 220,
"start": "2023-01-01",
"end": "2023-12-31",
"form": "10-K",
"fp": "FY",
"fy": 2023,
}
)
entries.append(
{
"tag": "Assets",
"val": 1000,
"end": "2023-12-31",
}
)
entries.append(
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
"form": "10-K",
"fp": "FY",
"fy": 2023,
}
)
mock = create_mock_facts(entries)
res = resolve_company_facts(mock, period="pop")
pop_recs = [r for r in res.income_statement if r["tag"] == "total_revenue"]
assert len(pop_recs) >= 1
latest = max(pop_recs, key=lambda r: r["period_ending"])
assert abs(latest["value"] - 20.0) < 0.01
assert latest["frequency"] == "pop"
def test_division_by_zero_skipped(self):
mock = self._annual_revenue([(2023, 0), (2024, 100)])
res = resolve_company_facts(mock, period="yoy")
yoy_recs = [
r
for r in res.income_statement
if r["tag"] == "total_revenue" and r["period_ending"] == "2024-12-31"
]
assert len(yoy_recs) == 0
def test_negative_to_positive(self):
mock = self._annual_revenue([(2023, -100), (2024, 50)])
res = resolve_company_facts(mock, period="yoy")
yoy_recs = [
r
for r in res.income_statement
if r["tag"] == "total_revenue" and r["period_ending"] == "2024-12-31"
]
assert len(yoy_recs) >= 1
assert abs(yoy_recs[0]["value"] - 1.5) < 0.01
def test_pct_change_unit_and_currency(self):
mock = self._annual_revenue([(2023, 1000), (2024, 1200)])
res = resolve_company_facts(mock, period="yoy")
yoy_recs = [r for r in res.income_statement if r["tag"] == "total_revenue"]
for rec in yoy_recs:
assert rec["unit"] == "percent"
assert rec["currency"] == ""
def test_provenance_string(self):
mock = self._annual_revenue([(2023, 1000), (2024, 1100)])
res = resolve_company_facts(mock, period="yoy")
yoy_recs = [
r
for r in res.income_statement
if r["tag"] == "total_revenue" and r["period_ending"] == "2024-12-31"
]
if yoy_recs:
src = yoy_recs[0]["source"]
assert "yoy:" in src
assert "2024-12-31" in src
assert "2023-12-31" in src
class TestPeriodType:
"""Tests for period_type handling in standardized records."""
def test_period_type_in_records(self):
mock = create_mock_facts(
[
{
"tag": "Revenues",
"val": 500,
"start": "2023-01-01",
"end": "2023-12-31",
},
{"tag": "Assets", "val": 1000, "end": "2023-12-31"},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 100,
"start": "2023-01-01",
"end": "2023-12-31",
},
]
)
res = resolve_company_facts(mock, period="annual")
rev = [r for r in res.income_statement if r["tag"] == "total_revenue"]
if rev:
assert "period_type" in rev[0]
assert rev[0]["period_type"] == "duration"
assets = [r for r in res.balance_sheet if r["tag"] == "total_assets"]
if assets:
assert assets[0]["period_type"] == "instant"
def _mini_result(rows, dates, currency="USD", fiscal=None, preliminary=None):
"""Build a minimal StatementResult-shaped namespace for _build_records."""
from types import SimpleNamespace
return SimpleNamespace(
currency=currency,
rows=rows,
dates=dates,
fiscal_data=fiscal or {},
preliminary_dates=preliminary or set(),
)
def _row(tag, values, sources=None):
from openbb_sec.utils.statement_schema._types import RowResult
return RowResult(
tag=tag,
label=tag.replace("_", " ").title(),
description="d",
parent=None,
sequence=1,
factor="+",
balance="debit",
unit="monetary",
period_type="duration",
values=values,
sources=sources or {},
)
def test_pct_change_pop_skips_zero_prior():
"""A period-over-period change from a zero prior value is not computed."""
def rec(date, value):
return {
"period_ending": date,
"fiscal_year": int(date[:4]),
"fiscal_period": "FY",
"calendar_year": int(date[:4]),
"calendar_period": "Q4",
"tag": "total_revenue",
"label": "Total Revenue",
"description": "d",
"parent": None,
"sequence": 1,
"factor": "+",
"balance": "credit",
"period_type": "duration",
"value": value,
}
records = [
rec("2021-12-31", 0.0),
rec("2022-12-31", 100.0),
rec("2023-12-31", 150.0),
]
out = cf._compute_pct_change(records, "pop")
assert [r["period_ending"] for r in out] == ["2023-12-31"]
assert out[0]["value"] == 50.0
def test_build_records_skips_all_zero_tags():
"""A tag that is all-zero (and not imputed/corrected) is dropped entirely."""
zero_row = _row("dead_tag", {"2023-12-31": 0, "2022-12-31": 0})
live_row = _row(
"live_tag",
{"2023-12-31": 100, "2022-12-31": 90},
sources={"2023-12-31": "Assets", "2022-12-31": "Assets"},
)
result = _mini_result([zero_row, live_row], ["2022-12-31", "2023-12-31"])
records = cf._build_records(result)
tags = {r["tag"] for r in records}
assert "dead_tag" not in tags # all-zero tag excluded
assert "live_tag" in tags
assert len(records) == 2 # one live tag x two dates
def test_build_records_zero_kept_when_imputed():
"""An all-zero tag whose source is 'imputed' is retained (not suspect-dropped)."""
imp_zero = _row(
"imp_tag",
{"2023-12-31": 0},
sources={"2023-12-31": "imputed: a - b"},
)
result = _mini_result([imp_zero], ["2023-12-31"])
records = cf._build_records(result)
assert len(records) == 1
# Suspect-zero policy rewrites 'imputed' -> 'imputed-zero'.
assert records[0]["source"].startswith("imputed-zero")
assert records[0]["value"] == 0
def test_build_records_corrected_zero_prefixed():
"""A 'corrected:' exact-zero takes the else-branch, prefixing 'imputed-zero: '."""
corrected_zero = _row(
"corr_tag",
{"2023-12-31": 0},
sources={"2023-12-31": "corrected: a - b"},
)
result = _mini_result([corrected_zero], ["2023-12-31"])
records = cf._build_records(result)
assert len(records) == 1
# Non-'imputed' suspect sources are wrapped, not replaced.
assert records[0]["source"] == "imputed-zero: corrected: a - b"
assert records[0]["value"] == 0
def test_build_records_fiscal_year_filter():
"""fiscal_years filters out periods whose fiscal_year is excluded."""
row = _row(
"rev",
{"2023-12-31": 100, "2022-12-31": 90},
sources={"2023-12-31": "Revenues", "2022-12-31": "Revenues"},
)
fiscal = {
"2023-12-31": {"fiscal_year": 2023, "fiscal_period": "FY"},
"2022-12-31": {"fiscal_year": 2022, "fiscal_period": "FY"},
}
result = _mini_result([row], ["2022-12-31", "2023-12-31"], fiscal=fiscal)
records = cf._build_records(result, fiscal_years=[2023])
assert {r["period_ending"] for r in records} == {"2023-12-31"}
def test_get_standardized_financials_int_cik(blk_facts):
"""An integer CIK is zero-padded and fetched once via cached_request."""
calls = []
async def _fake_request(url, **kwargs):
calls.append(url)
return blk_facts
with patch("openbb_sec.utils.cache.cached_request", _fake_request):
res = asyncio.run(cf.get_standardized_financials(cik=2012383, period="annual"))
assert len(calls) == 1
assert "CIK0002012383.json" in calls[0]
assert res.entity_name == "BlackRock, Inc."
assert len(res.income_statement) > 0
def test_get_standardized_financials_symbol_maps_to_cik(blk_facts):
"""A symbol is resolved through symbol_map then fetched."""
async def _fake_symbol_map(symbol, use_cache=True):
return "0002012383"
async def _fake_request(url, **kwargs):
return blk_facts
with (
patch("openbb_sec.utils.helpers.symbol_map", _fake_symbol_map),
patch("openbb_sec.utils.cache.cached_request", _fake_request),
):
res = asyncio.run(
cf.get_standardized_financials(symbol="BLKX", period="annual")
)
assert res.entity_name == "BlackRock, Inc."
def test_get_standardized_financials_symbol_no_cik_raises():
"""A symbol that maps to an empty CIK raises a not-found error."""
async def _fake_symbol_map(symbol, use_cache=True):
return ""
with patch("openbb_sec.utils.helpers.symbol_map", _fake_symbol_map):
with pytest.raises(OpenBBError, match="Could not find CIK"):
asyncio.run(cf.get_standardized_financials(symbol="NOPE"))
def test_get_standardized_financials_requires_input():
"""Neither symbol nor cik raises an error."""
with pytest.raises(OpenBBError, match="Either symbol or cik"):
asyncio.run(cf.get_standardized_financials())
def test_get_standardized_financials_empty_string_cik_raises():
"""An empty-string cik resolves to an empty cik_list and raises.
Distinct from the None case: ``cik=""`` enters the ``isinstance(cik, str)``
branch which produces ``[]``, then the ``if not cik_list`` guard fires.
"""
with pytest.raises(OpenBBError, match="Either symbol or cik"):
asyncio.run(cf.get_standardized_financials(cik=""))
def test_get_standardized_financials_unexpected_response():
"""A response without a 'facts' key raises an unexpected-response error."""
async def _fake_request(url, **kwargs):
return {"not": "facts"}
with patch("openbb_sec.utils.cache.cached_request", _fake_request):
with pytest.raises(OpenBBError, match="Unexpected response"):
asyncio.run(cf.get_standardized_financials(cik="0000320193"))
def test_get_standardized_financials_multi_cik_merge(blk_facts):
"""A MULTI_CIK_TICKERS symbol fetches every CIK and merges the facts."""
calls = []
async def _fake_symbol_map(symbol, use_cache=True):
return "0002012383"
async def _fake_request(url, **kwargs):
calls.append(url)
return blk_facts
# BLK is in MULTI_CIK_TICKERS with two CIKs. symbol_map is still called for
# the not-found guard before MULTI_CIK_TICKERS is consulted, so it must be
# patched at its source (helpers binds cached_request at import, so the
# cached_request patch alone does not intercept get_all_companies).
with (
patch("openbb_sec.utils.helpers.symbol_map", _fake_symbol_map),
patch("openbb_sec.utils.cache.cached_request", _fake_request),
):
res = asyncio.run(cf.get_standardized_financials(symbol="BLK", period="annual"))
assert len(calls) == 2 # one request per CIK
assert res.entity_name == "BlackRock, Inc."
def _six_k_filer_facts():
"""Build facts for a 40-F/20-F filer that furnishes financials via 6-K.
Mirrors real foreign private issuers (e.g. Canadian National Railway):
most fiscal years are annual-only (the full-year statements are tagged on
a 6-K exhibit rather than in the 40-F/20-F), while a few earlier years
additionally carry genuine interim (3/6/9-month) periods on 6-K.
"""
entries: list[dict] = []
# Annual-only years: only a full-year period is reported, via 6-K, filed
# ~6 weeks after year-end (as real FY 6-K exhibits are).
for year, assets, rev in ((2021, 23000, 7600), (2022, 24000, 8000)):
filed = f"{year + 1}-02-15"
entries += [
{
"tag": "Assets",
"val": assets,
"end": f"{year}-12-31",
"form": "6-K",
"filed": filed,
},
{
"tag": "Revenues",
"val": rev,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
"form": "6-K",
"fp": "FY",
"filed": filed,
},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 2000,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
"form": "6-K",
"fp": "FY",
"filed": filed,
},
]
# 2023: full-year plus genuine interim periods (cumulative 3/6/9-month).
entries += [
{
"tag": "Assets",
"val": 26000,
"end": "2023-12-31",
"form": "6-K",
"filed": "2024-02-15",
},
{
"tag": "Revenues",
"val": 9000,
"start": "2023-01-01",
"end": "2023-12-31",
"form": "6-K",
"fp": "FY",
"filed": "2024-02-15",
},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": 2200,
"start": "2023-01-01",
"end": "2023-12-31",
"form": "6-K",
"fp": "FY",
"filed": "2024-02-15",
},
]
for end, filed, assets, rev, cash in (
("2023-03-31", "2023-05-01", 24500, 2000, 500), # ~90 days
("2023-06-30", "2023-08-01", 25000, 4100, 1000), # ~181 days
("2023-09-30", "2023-11-01", 25500, 6200, 1500), # ~273 days (9-month)
):
entries += [
{
"tag": "Assets",
"val": assets,
"end": end,
"form": "6-K",
"filed": filed,
},
{
"tag": "Revenues",
"val": rev,
"start": "2023-01-01",
"end": end,
"form": "6-K",
"filed": filed,
},
{
"tag": "NetCashProvidedByUsedInOperatingActivities",
"val": cash,
"start": "2023-01-01",
"end": end,
"form": "6-K",
"filed": filed,
},
]
return create_mock_facts(entries)
def _six_k_lapsed_interim_facts():
"""Build facts for a 6-K filer whose interim reporting lapsed.
Annual (FY) periods continue through 2024, but the only interim periods
are from 2020 — the quarterly series would be sparse/stale, so it must be
treated as no quarterly data.
"""
entries: list[dict] = []
for year in (2020, 2021, 2022, 2023, 2024):
filed = f"{year + 1}-02-15"
entries += [
{
"tag": "Assets",
"val": 24000 + year,
"end": f"{year}-12-31",
"form": "6-K",
"filed": filed,
},
{
"tag": "Revenues",
"val": 8000,
"start": f"{year}-01-01",
"end": f"{year}-12-31",
"form": "6-K",
"fp": "FY",
"filed": filed,
},
]
# Lone interim periods, only for 2020.
for end, filed in (("2020-06-30", "2020-08-01"), ("2020-09-30", "2020-11-01")):
entries += [
{"tag": "Assets", "val": 23500, "end": end, "form": "6-K", "filed": filed},
{
"tag": "Revenues",
"val": 2000,
"start": "2020-01-01",
"end": end,
"form": "6-K",
"filed": filed,
},
]
return create_mock_facts(entries)
class TestSixKReportingPeriods:
"""Period detection for 40-F/20-F filers that report via 6-K exhibits."""
def test_annual_fy_periods_detected_from_6k(self):
facts = _six_k_filer_facts()["facts"]
annual = get_filing_dates(facts, "annual")
assert annual == {"2021-12-31", "2022-12-31", "2023-12-31"}
def test_nine_month_interim_period_detected(self):
facts = _six_k_filer_facts()["facts"]
quarterly = get_filing_dates(facts, "quarterly")
assert {"2023-03-31", "2023-06-30", "2023-09-30"} <= quarterly
def test_annual_only_years_excluded_from_quarterly(self):
# Years with no interim data must not surface as phantom Q4/H2 rows.
facts = _six_k_filer_facts()["facts"]
quarterly = get_filing_dates(facts, "quarterly")
assert "2021-12-31" not in quarterly
assert "2022-12-31" not in quarterly
def test_annual_balance_sheet_not_empty(self):
res = resolve_company_facts(_six_k_filer_facts(), period="annual")
assert res.balance_sheet, "annual balance sheet must not be empty for 6-K filer"
dates = {r["period_ending"] for r in res.balance_sheet}
assert {"2021-12-31", "2022-12-31", "2023-12-31"} <= dates
assert all(r["fiscal_period"] == "FY" for r in res.balance_sheet)
def test_annual_total_assets_value(self):
res = resolve_company_facts(_six_k_filer_facts(), period="annual")
ta = {
r["period_ending"]: r["value"]
for r in res.balance_sheet
if r["tag"] == "total_assets"
}
assert ta.get("2023-12-31") == 26000
def test_quarterly_excludes_annual_only_years(self):
res = resolve_company_facts(_six_k_filer_facts(), period="quarterly")
dates = {r["period_ending"] for r in res.balance_sheet}
assert "2021-12-31" not in dates
assert "2022-12-31" not in dates
def test_lapsed_interim_yields_no_quarterly_dates(self):
# Interim reporting that stopped years before the latest annual period
# is discontinuous and must be treated as no quarterly data.
facts = _six_k_lapsed_interim_facts()["facts"]
assert get_filing_dates(facts, "quarterly") == set()
assert get_filing_dates(facts, "annual") == {
"2020-12-31",
"2021-12-31",
"2022-12-31",
"2023-12-31",
"2024-12-31",
}
def test_lapsed_interim_quarterly_raises(self):
from openbb_core.app.model.abstract.error import OpenBBError
with pytest.raises(OpenBBError, match="quarterly"):
resolve_company_facts(_six_k_lapsed_interim_facts(), period="quarterly")