finvariant 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Atakan Arikan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: finvariant
3
+ Version: 0.1.0
4
+ Summary: Deterministic integrity checks for financial statements: does the balance sheet balance, does the cash flow tie out, do the three statements articulate. Verifies, does not parse or build.
5
+ Author: Atakan Arikan
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/arikanatakan/finvariant
8
+ Project-URL: Repository, https://github.com/arikanatakan/finvariant
9
+ Project-URL: Issues, https://github.com/arikanatakan/finvariant/issues
10
+ Keywords: accounting,financial-statements,audit,tie-out,integrity,balance-sheet,income-statement,cash-flow,three-statement-model,financial-modeling
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Financial and Insurance Industry
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7; extra == "dev"
25
+ Requires-Dist: ruff; extra == "dev"
26
+ Requires-Dist: build; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # finvariant
30
+
31
+ [![CI](https://github.com/arikanatakan/finvariant/actions/workflows/ci.yml/badge.svg)](https://github.com/arikanatakan/finvariant/actions/workflows/ci.yml)
32
+ [![PyPI](https://img.shields.io/pypi/v/finvariant)](https://pypi.org/project/finvariant/)
33
+ [![License: MIT](https://img.shields.io/github/license/arikanatakan/finvariant)](LICENSE)
34
+
35
+ Deterministic integrity checks for financial statements.
36
+
37
+ Give finvariant income statement, balance sheet and cash flow data; it verifies
38
+ the accounting invariants - the balance sheet balances, the cash flow ties to
39
+ the balance sheet, subtotals foot, and the three statements articulate - and
40
+ returns a structured, auditable report. It verifies; it does not parse, fetch
41
+ or build statements.
42
+
43
+ ## Motivation
44
+
45
+ Python has plenty of libraries to *retrieve* statements (financetoolkit, the SEC
46
+ tools) and to *build* models (DCF templates, FP&A scripts). What none of them do
47
+ is *check that a set of statements or a model is internally consistent*: that
48
+ assets equal liabilities plus equity, that the cash flow's ending cash matches
49
+ the balance sheet, that retained earnings roll forward by net income less
50
+ dividends, that every subtotal foots. That check is exactly what a spreadsheet
51
+ silently gets wrong - and surveys put an error in the large majority of business
52
+ spreadsheets.
53
+
54
+ finvariant encodes those invariants as deterministic, testable rules. The same
55
+ thing a large language model cannot be trusted to get right (consistent
56
+ arithmetic across linked statements), a small library can guarantee. Every
57
+ result is one report: a verdict, the exact failing checks with expected vs
58
+ actual, and provenance, so a verification can be reproduced and audited later.
59
+
60
+ ```
61
+ pip install finvariant
62
+ ```
63
+
64
+ No runtime dependencies.
65
+
66
+ ## Usage
67
+
68
+ Catch an error:
69
+
70
+ ```python
71
+ import finvariant as fv
72
+
73
+ s = fv.Statements(
74
+ periods=["FY2024"],
75
+ balance_sheet={"FY2024": {
76
+ "total_assets": 540, # should be 538
77
+ "total_liabilities": 158,
78
+ "total_equity": 380,
79
+ }},
80
+ )
81
+
82
+ r = fv.check(s)
83
+ r.ok # False
84
+ print(r.summary())
85
+ # finvariant audit - 2026-...
86
+ # 1 checks run, 0 passed, 1 failed, 0 skipped
87
+ # [ERROR] EQ.accounting_equation assets = liabilities + equity (FY2024): expected 538, got 540, off by 2
88
+ # Verdict: FAIL - statements do not tie out
89
+ ```
90
+
91
+ Real statements tie out (Apple FY2024, from the 10-K):
92
+
93
+ ```python
94
+ s = fv.Statements(
95
+ periods=["FY2024"],
96
+ income_statement={"FY2024": {
97
+ "revenue": 391035, "cogs": 210352, "gross_profit": 180683,
98
+ "operating_expenses": 57467, "operating_income": 123216,
99
+ "other_income": 269, "pretax_income": 123485, "tax": 29749,
100
+ "net_income": 93736,
101
+ }},
102
+ balance_sheet={"FY2024": {
103
+ "total_current_assets": 152987, "total_non_current_assets": 211993,
104
+ "total_assets": 364980,
105
+ "total_current_liabilities": 176392, "total_non_current_liabilities": 131638,
106
+ "total_liabilities": 308030,
107
+ "common_stock": 83276, "retained_earnings": -19154,
108
+ "accumulated_oci": -7172, "total_equity": 56950,
109
+ }},
110
+ )
111
+ fv.check(s).ok # True
112
+ ```
113
+
114
+ The report carries named findings, counts, `ok`, `summary()` and a JSON-safe
115
+ `to_dict()` with provenance (version, input hash, timestamp).
116
+
117
+ ## What it checks
118
+
119
+ | Group | Invariant |
120
+ |-------|-----------|
121
+ | Footing | every subtotal equals the sum of its line items (all three statements) |
122
+ | Equation | total assets = total liabilities + total equity |
123
+ | Cash | net change = cfo + cfi + cff; ending cash ties to the balance sheet; beginning cash ties to the prior period |
124
+ | Articulation | net income agrees across statements; retained earnings roll forward by net income less dividends |
125
+
126
+ Provide only the fields you have: a check whose inputs are missing is reported
127
+ as skipped, never failed. Tolerances absorb the rounding in statements reported
128
+ in whole millions.
129
+
130
+ ## Status
131
+
132
+ Version 0.1.0. Single entity, single currency, one or more periods, in a
133
+ canonical schema. The `Statements` input and `AuditReport` output are the
134
+ contract and are append-only from here.
135
+
136
+ ## Roadmap
137
+
138
+ | Version | Scope |
139
+ |---------|-------|
140
+ | 0.2 | roll-forward checks (PP&E = opening + capex - depreciation - disposals; debt; equity); working-capital changes reconciled to operating cash flow |
141
+ | 0.3 | an MCP server so an agent can verify financial statements it reads or generates |
142
+ | 0.4 | optional readers to map common export formats into the canonical schema |
143
+
144
+ Out of scope: retrieving statements (see financetoolkit, the SEC tools),
145
+ building or forecasting models, ratio analysis, consolidation and currency
146
+ translation.
147
+
148
+ ## License
149
+
150
+ MIT. Written and maintained by [Atakan Arikan](https://github.com/arikanatakan),
151
+ MSc Student at Tsinghua University and Politecnico di Milano.
@@ -0,0 +1,123 @@
1
+ # finvariant
2
+
3
+ [![CI](https://github.com/arikanatakan/finvariant/actions/workflows/ci.yml/badge.svg)](https://github.com/arikanatakan/finvariant/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/finvariant)](https://pypi.org/project/finvariant/)
5
+ [![License: MIT](https://img.shields.io/github/license/arikanatakan/finvariant)](LICENSE)
6
+
7
+ Deterministic integrity checks for financial statements.
8
+
9
+ Give finvariant income statement, balance sheet and cash flow data; it verifies
10
+ the accounting invariants - the balance sheet balances, the cash flow ties to
11
+ the balance sheet, subtotals foot, and the three statements articulate - and
12
+ returns a structured, auditable report. It verifies; it does not parse, fetch
13
+ or build statements.
14
+
15
+ ## Motivation
16
+
17
+ Python has plenty of libraries to *retrieve* statements (financetoolkit, the SEC
18
+ tools) and to *build* models (DCF templates, FP&A scripts). What none of them do
19
+ is *check that a set of statements or a model is internally consistent*: that
20
+ assets equal liabilities plus equity, that the cash flow's ending cash matches
21
+ the balance sheet, that retained earnings roll forward by net income less
22
+ dividends, that every subtotal foots. That check is exactly what a spreadsheet
23
+ silently gets wrong - and surveys put an error in the large majority of business
24
+ spreadsheets.
25
+
26
+ finvariant encodes those invariants as deterministic, testable rules. The same
27
+ thing a large language model cannot be trusted to get right (consistent
28
+ arithmetic across linked statements), a small library can guarantee. Every
29
+ result is one report: a verdict, the exact failing checks with expected vs
30
+ actual, and provenance, so a verification can be reproduced and audited later.
31
+
32
+ ```
33
+ pip install finvariant
34
+ ```
35
+
36
+ No runtime dependencies.
37
+
38
+ ## Usage
39
+
40
+ Catch an error:
41
+
42
+ ```python
43
+ import finvariant as fv
44
+
45
+ s = fv.Statements(
46
+ periods=["FY2024"],
47
+ balance_sheet={"FY2024": {
48
+ "total_assets": 540, # should be 538
49
+ "total_liabilities": 158,
50
+ "total_equity": 380,
51
+ }},
52
+ )
53
+
54
+ r = fv.check(s)
55
+ r.ok # False
56
+ print(r.summary())
57
+ # finvariant audit - 2026-...
58
+ # 1 checks run, 0 passed, 1 failed, 0 skipped
59
+ # [ERROR] EQ.accounting_equation assets = liabilities + equity (FY2024): expected 538, got 540, off by 2
60
+ # Verdict: FAIL - statements do not tie out
61
+ ```
62
+
63
+ Real statements tie out (Apple FY2024, from the 10-K):
64
+
65
+ ```python
66
+ s = fv.Statements(
67
+ periods=["FY2024"],
68
+ income_statement={"FY2024": {
69
+ "revenue": 391035, "cogs": 210352, "gross_profit": 180683,
70
+ "operating_expenses": 57467, "operating_income": 123216,
71
+ "other_income": 269, "pretax_income": 123485, "tax": 29749,
72
+ "net_income": 93736,
73
+ }},
74
+ balance_sheet={"FY2024": {
75
+ "total_current_assets": 152987, "total_non_current_assets": 211993,
76
+ "total_assets": 364980,
77
+ "total_current_liabilities": 176392, "total_non_current_liabilities": 131638,
78
+ "total_liabilities": 308030,
79
+ "common_stock": 83276, "retained_earnings": -19154,
80
+ "accumulated_oci": -7172, "total_equity": 56950,
81
+ }},
82
+ )
83
+ fv.check(s).ok # True
84
+ ```
85
+
86
+ The report carries named findings, counts, `ok`, `summary()` and a JSON-safe
87
+ `to_dict()` with provenance (version, input hash, timestamp).
88
+
89
+ ## What it checks
90
+
91
+ | Group | Invariant |
92
+ |-------|-----------|
93
+ | Footing | every subtotal equals the sum of its line items (all three statements) |
94
+ | Equation | total assets = total liabilities + total equity |
95
+ | Cash | net change = cfo + cfi + cff; ending cash ties to the balance sheet; beginning cash ties to the prior period |
96
+ | Articulation | net income agrees across statements; retained earnings roll forward by net income less dividends |
97
+
98
+ Provide only the fields you have: a check whose inputs are missing is reported
99
+ as skipped, never failed. Tolerances absorb the rounding in statements reported
100
+ in whole millions.
101
+
102
+ ## Status
103
+
104
+ Version 0.1.0. Single entity, single currency, one or more periods, in a
105
+ canonical schema. The `Statements` input and `AuditReport` output are the
106
+ contract and are append-only from here.
107
+
108
+ ## Roadmap
109
+
110
+ | Version | Scope |
111
+ |---------|-------|
112
+ | 0.2 | roll-forward checks (PP&E = opening + capex - depreciation - disposals; debt; equity); working-capital changes reconciled to operating cash flow |
113
+ | 0.3 | an MCP server so an agent can verify financial statements it reads or generates |
114
+ | 0.4 | optional readers to map common export formats into the canonical schema |
115
+
116
+ Out of scope: retrieving statements (see financetoolkit, the SEC tools),
117
+ building or forecasting models, ratio analysis, consolidation and currency
118
+ translation.
119
+
120
+ ## License
121
+
122
+ MIT. Written and maintained by [Atakan Arikan](https://github.com/arikanatakan),
123
+ MSc Student at Tsinghua University and Politecnico di Milano.
@@ -0,0 +1,28 @@
1
+ """finvariant - deterministic integrity checks for financial statements.
2
+
3
+ Give it income statement, balance sheet and cash flow data in a canonical
4
+ schema; it verifies the accounting invariants - the balance sheet balances,
5
+ the cash flow ties to the balance sheet, subtotals foot, and the three
6
+ statements articulate - and returns a structured, auditable report.
7
+
8
+ import finvariant as fv
9
+
10
+ s = fv.Statements(periods=["FY2024"],
11
+ income_statement={"FY2024": {...}},
12
+ balance_sheet={"FY2024": {...}},
13
+ cash_flow={"FY2024": {...}})
14
+
15
+ r = fv.check(s)
16
+ r.ok # do the statements tie out?
17
+ r.summary() # plain-language verdict with every failed check
18
+ r.to_dict() # JSON-safe payload with provenance
19
+
20
+ It verifies; it does not parse, fetch or build statements.
21
+ """
22
+
23
+ from ._result import AuditReport, RuleOutcome
24
+ from ._version import __version__
25
+ from .check import check
26
+ from .model import Statements
27
+
28
+ __all__ = ["check", "Statements", "AuditReport", "RuleOutcome", "__version__"]
@@ -0,0 +1,126 @@
1
+ """The result contract: one ``AuditReport`` for every check, with provenance
2
+ and a JSON-safe payload, so a verification can be reproduced and audited later.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+
12
+ SCHEMA = 1
13
+
14
+ PASS = "pass"
15
+ FAIL = "fail"
16
+ SKIP = "skip"
17
+ ERROR = "error"
18
+ WARNING = "warning"
19
+
20
+
21
+ def utcnow() -> str:
22
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
23
+
24
+
25
+ def data_hash(obj: object) -> str:
26
+ payload = json.dumps(obj, sort_keys=True, default=str).encode("utf-8")
27
+ return "sha256:" + hashlib.sha256(payload).hexdigest()[:16]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RuleOutcome:
32
+ """The result of one rule on one period."""
33
+
34
+ rule_id: str
35
+ description: str
36
+ statement: str
37
+ period: str
38
+ status: str # pass | fail | skip
39
+ severity: str # error | warning
40
+ expected: float | None = None
41
+ actual: float | None = None
42
+ difference: float | None = None
43
+ message: str = ""
44
+
45
+ def __str__(self) -> str:
46
+ tag = self.severity.upper() if self.status == FAIL else self.status.upper()
47
+ head = f"[{tag}] {self.rule_id} {self.description} ({self.period})"
48
+ if self.status == FAIL and self.expected is not None:
49
+ return (f"{head}: expected {self.expected:g}, got {self.actual:g}, "
50
+ f"off by {self.difference:g}")
51
+ return head if not self.message else f"{head}: {self.message}"
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class AuditReport:
56
+ """Outcome of checking a set of statements against the accounting invariants."""
57
+
58
+ outcomes: tuple[RuleOutcome, ...]
59
+ meta: dict
60
+
61
+ @property
62
+ def findings(self) -> tuple[RuleOutcome, ...]:
63
+ """The rules that failed."""
64
+ return tuple(o for o in self.outcomes if o.status == FAIL)
65
+
66
+ @property
67
+ def ok(self) -> bool:
68
+ """True when no error-severity rule failed."""
69
+ return not any(o.status == FAIL and o.severity == ERROR for o in self.outcomes)
70
+
71
+ @property
72
+ def n_passed(self) -> int:
73
+ return sum(1 for o in self.outcomes if o.status == PASS)
74
+
75
+ @property
76
+ def n_failed(self) -> int:
77
+ return sum(1 for o in self.outcomes if o.status == FAIL)
78
+
79
+ @property
80
+ def n_skipped(self) -> int:
81
+ return sum(1 for o in self.outcomes if o.status == SKIP)
82
+
83
+ def _verdict(self) -> str:
84
+ if not self.ok:
85
+ return "FAIL - statements do not tie out"
86
+ warnings = any(o.status == FAIL and o.severity == WARNING for o in self.outcomes)
87
+ return "PASS (with warnings)" if warnings else "PASS - statements tie out"
88
+
89
+ def summary(self) -> str:
90
+ run = self.n_passed + self.n_failed
91
+ lines = [
92
+ f"finvariant audit - {self.meta.get('computed_at', '')}",
93
+ f" {run} checks run, {self.n_passed} passed, {self.n_failed} failed, "
94
+ f"{self.n_skipped} skipped",
95
+ ]
96
+ for finding in self.findings:
97
+ lines.append(" " + str(finding))
98
+ lines.append(f"Verdict: {self._verdict()}")
99
+ return "\n".join(lines)
100
+
101
+ def to_dict(self) -> dict:
102
+ return {
103
+ "schema": SCHEMA,
104
+ "ok": self.ok,
105
+ "verdict": self._verdict(),
106
+ "counts": {
107
+ "passed": self.n_passed,
108
+ "failed": self.n_failed,
109
+ "skipped": self.n_skipped,
110
+ },
111
+ "findings": [
112
+ {
113
+ "rule_id": o.rule_id,
114
+ "description": o.description,
115
+ "statement": o.statement,
116
+ "period": o.period,
117
+ "severity": o.severity,
118
+ "expected": o.expected,
119
+ "actual": o.actual,
120
+ "difference": o.difference,
121
+ "message": o.message,
122
+ }
123
+ for o in self.findings
124
+ ],
125
+ "meta": self.meta,
126
+ }
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,55 @@
1
+ """The public entry point: ``check()`` runs the applicable invariants over a set
2
+ of statements and returns an :class:`AuditReport`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Iterable, Mapping
8
+
9
+ from ._result import AuditReport, data_hash, utcnow
10
+ from ._version import __version__
11
+ from .model import Statements
12
+ from .rules import run_rules
13
+
14
+
15
+ def _jsonable(table: Mapping) -> dict:
16
+ return {period: dict(values) for period, values in table.items()}
17
+
18
+
19
+ def check(statements: Statements, *, abs_tol: float = 0.5, rel_tol: float = 1e-4,
20
+ rules: Iterable[str] | None = None) -> AuditReport:
21
+ """Check ``statements`` against the accounting invariants.
22
+
23
+ Parameters
24
+ ----------
25
+ statements:
26
+ A :class:`Statements` instance in the canonical schema.
27
+ abs_tol, rel_tol:
28
+ A check passes when the discrepancy is within
29
+ ``max(abs_tol, rel_tol * abs(expected))``. The defaults absorb the
30
+ rounding found in statements reported in whole millions.
31
+ rules:
32
+ Optional set of rule ids to restrict the report to.
33
+ """
34
+ if not isinstance(statements, Statements):
35
+ raise TypeError("check() expects a Statements instance")
36
+
37
+ outcomes = run_rules(statements, abs_tol, rel_tol)
38
+ if rules is not None:
39
+ wanted = set(rules)
40
+ outcomes = [o for o in outcomes if o.rule_id in wanted]
41
+
42
+ meta = {
43
+ "computed_at": utcnow(),
44
+ "version": __version__,
45
+ "periods": list(statements.periods),
46
+ "abs_tol": abs_tol,
47
+ "rel_tol": rel_tol,
48
+ "input_hash": data_hash({
49
+ "periods": list(statements.periods),
50
+ "is": _jsonable(statements.income_statement),
51
+ "bs": _jsonable(statements.balance_sheet),
52
+ "cf": _jsonable(statements.cash_flow),
53
+ }),
54
+ }
55
+ return AuditReport(tuple(outcomes), meta)
@@ -0,0 +1,145 @@
1
+ """Canonical financial-statement schema and the ``Statements`` container.
2
+
3
+ Sign convention (the checks assume it; finvariant does not parse or convert):
4
+
5
+ - Income statement: revenue positive; expenses (``cogs``, ``operating_expenses``,
6
+ ``interest_expense``, ``tax``) entered as positive magnitudes, so
7
+ ``net_income = ((revenue - cogs) - operating_expenses + other_income
8
+ - interest_expense) - tax``.
9
+ - Balance sheet: every line positive as normally presented. ``treasury_stock``
10
+ and an accumulated deficit may be negative.
11
+ - Cash flow: every line signed by its effect on cash (inflows positive,
12
+ outflows negative). So ``capex``, ``debt_repaid``, ``share_buybacks`` and
13
+ ``dividends_paid`` are negative, and
14
+ ``net_change_in_cash = cfo + cfi + cff + fx_effect``.
15
+
16
+ Provide only the fields you have. A check whose inputs are missing is reported
17
+ as skipped, never failed. To check that a subtotal foots, provide its line
18
+ items as well as the subtotal; partial line items are reported as not footing.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from dataclasses import dataclass, field
25
+ from typing import Mapping
26
+
27
+ # Income statement: (subtotal, plus_terms, minus_terms). A subtotal must equal
28
+ # the sum of the plus terms minus the sum of the minus terms.
29
+ IS_RELATIONS: list[tuple[str, list[str], list[str]]] = [
30
+ ("gross_profit", ["revenue"], ["cogs"]),
31
+ ("operating_income", ["gross_profit"], ["operating_expenses"]),
32
+ ("pretax_income", ["operating_income", "other_income"], ["interest_expense"]),
33
+ ("net_income", ["pretax_income"], ["tax"]),
34
+ ]
35
+ IS_FIELDS: set[str] = {
36
+ "revenue", "cogs", "gross_profit", "operating_expenses", "operating_income",
37
+ "other_income", "interest_expense", "pretax_income", "tax", "net_income",
38
+ "depreciation_amortization",
39
+ }
40
+
41
+ # Balance sheet sections: subtotal -> line items that should sum to it.
42
+ BS_SECTIONS: dict[str, list[str]] = {
43
+ "total_current_assets": [
44
+ "cash", "short_term_investments", "accounts_receivable", "inventory",
45
+ "prepaid_expenses", "other_current_assets",
46
+ ],
47
+ "total_non_current_assets": [
48
+ "ppe_net", "goodwill", "intangible_assets", "long_term_investments",
49
+ "deferred_tax_assets", "other_non_current_assets",
50
+ ],
51
+ "total_current_liabilities": [
52
+ "accounts_payable", "short_term_debt", "accrued_liabilities",
53
+ "deferred_revenue", "current_portion_long_term_debt",
54
+ "other_current_liabilities",
55
+ ],
56
+ "total_non_current_liabilities": [
57
+ "long_term_debt", "deferred_tax_liabilities", "pension_liabilities",
58
+ "other_non_current_liabilities",
59
+ ],
60
+ "total_equity": [
61
+ "common_stock", "additional_paid_in_capital", "retained_earnings",
62
+ "treasury_stock", "accumulated_oci", "minority_interest", "other_equity",
63
+ ],
64
+ }
65
+ # Higher-level balance-sheet sums.
66
+ BS_TOTALS: dict[str, list[str]] = {
67
+ "total_assets": ["total_current_assets", "total_non_current_assets"],
68
+ "total_liabilities": ["total_current_liabilities", "total_non_current_liabilities"],
69
+ }
70
+ BS_FIELDS: set[str] = (
71
+ {item for items in BS_SECTIONS.values() for item in items}
72
+ | set(BS_SECTIONS) | set(BS_TOTALS)
73
+ )
74
+
75
+ # Cash flow sections: subtotal -> line items that should sum to it.
76
+ CF_SECTIONS: dict[str, list[str]] = {
77
+ "cfo": [
78
+ "net_income", "depreciation_amortization", "stock_based_compensation",
79
+ "deferred_taxes", "change_in_accounts_receivable", "change_in_inventory",
80
+ "change_in_accounts_payable", "change_in_working_capital", "other_operating",
81
+ ],
82
+ "cfi": [
83
+ "capex", "acquisitions", "divestitures", "purchases_of_investments",
84
+ "sales_of_investments", "other_investing",
85
+ ],
86
+ "cff": [
87
+ "debt_issued", "debt_repaid", "equity_issued", "share_buybacks",
88
+ "dividends_paid", "other_financing",
89
+ ],
90
+ }
91
+ CF_OTHER: list[str] = ["fx_effect", "net_change_in_cash", "beginning_cash", "ending_cash"]
92
+ CF_FIELDS: set[str] = (
93
+ {item for items in CF_SECTIONS.values() for item in items}
94
+ | set(CF_SECTIONS) | set(CF_OTHER)
95
+ )
96
+
97
+
98
+ def _validate_period(name: str, period: str, values: Mapping[str, float],
99
+ valid: set[str]) -> None:
100
+ unknown = sorted(set(values) - valid)
101
+ if unknown:
102
+ raise ValueError(
103
+ f"{name} for period {period!r} has unknown fields: {unknown}. "
104
+ f"Map them to a canonical role or an 'other_*' line."
105
+ )
106
+ for key, value in values.items():
107
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
108
+ raise ValueError(f"{name}[{period!r}][{key!r}] must be a number")
109
+ if not math.isfinite(float(value)):
110
+ raise ValueError(f"{name}[{period!r}][{key!r}] must be finite")
111
+
112
+
113
+ @dataclass
114
+ class Statements:
115
+ """A set of financial statements, by period, in the canonical schema."""
116
+
117
+ periods: list[str]
118
+ income_statement: Mapping[str, Mapping[str, float]] = field(default_factory=dict)
119
+ balance_sheet: Mapping[str, Mapping[str, float]] = field(default_factory=dict)
120
+ cash_flow: Mapping[str, Mapping[str, float]] = field(default_factory=dict)
121
+
122
+ def __post_init__(self) -> None:
123
+ if not self.periods or not all(isinstance(p, str) for p in self.periods):
124
+ raise ValueError("periods must be a non-empty list of period labels")
125
+ if len(set(self.periods)) != len(self.periods):
126
+ raise ValueError("periods must be unique")
127
+ known = set(self.periods)
128
+ for name, table, valid in (
129
+ ("income_statement", self.income_statement, IS_FIELDS),
130
+ ("balance_sheet", self.balance_sheet, BS_FIELDS),
131
+ ("cash_flow", self.cash_flow, CF_FIELDS),
132
+ ):
133
+ extra = set(table) - known
134
+ if extra:
135
+ raise ValueError(f"{name} has periods not in 'periods': {sorted(extra)}")
136
+ for period, values in table.items():
137
+ _validate_period(name, period, values, valid)
138
+
139
+ def at(self, period: str) -> dict[str, dict[str, float]]:
140
+ """The three statements for one period as plain dicts (missing -> empty)."""
141
+ return {
142
+ "is": dict(self.income_statement.get(period, {})),
143
+ "bs": dict(self.balance_sheet.get(period, {})),
144
+ "cf": dict(self.cash_flow.get(period, {})),
145
+ }
File without changes
@@ -0,0 +1,189 @@
1
+ """The accounting-invariant catalogue.
2
+
3
+ Each rule reads only the fields it needs. A rule whose inputs are absent emits
4
+ nothing; a subtotal given without its line items emits a ``skip``; an applicable
5
+ rule emits ``pass`` or ``fail``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ._result import ERROR, FAIL, PASS, SKIP, WARNING, RuleOutcome
11
+ from .model import BS_SECTIONS, BS_TOTALS, CF_SECTIONS, IS_RELATIONS, Statements
12
+
13
+ IS_DESC = {
14
+ "gross_profit": "gross profit = revenue - cogs",
15
+ "operating_income": "operating income = gross profit - operating expenses",
16
+ "pretax_income": "pretax income = operating income + other income - interest",
17
+ "net_income": "net income = pretax income - tax",
18
+ }
19
+ BS_SECTION_DESC = {
20
+ "total_current_assets": "current assets foot",
21
+ "total_non_current_assets": "non-current assets foot",
22
+ "total_current_liabilities": "current liabilities foot",
23
+ "total_non_current_liabilities": "non-current liabilities foot",
24
+ "total_equity": "equity foots",
25
+ }
26
+ BS_TOTAL_DESC = {
27
+ "total_assets": "total assets = current + non-current assets",
28
+ "total_liabilities": "total liabilities = current + non-current liabilities",
29
+ }
30
+ CF_SECTION_DESC = {
31
+ "cfo": "operating cash flow foots",
32
+ "cfi": "investing cash flow foots",
33
+ "cff": "financing cash flow foots",
34
+ }
35
+
36
+
37
+ def _present(d: dict, keys: list[str]) -> list[str]:
38
+ return [k for k in keys if k in d]
39
+
40
+
41
+ def _sum(d: dict, keys: list[str]) -> float:
42
+ return sum(d[k] for k in keys if k in d)
43
+
44
+
45
+ def _close(expected: float, actual: float, abs_tol: float, rel_tol: float) -> bool:
46
+ return abs(actual - expected) <= max(abs_tol, rel_tol * abs(expected))
47
+
48
+
49
+ def _check(rule_id, desc, statement, period, severity,
50
+ expected, actual, abs_tol, rel_tol) -> RuleOutcome:
51
+ status = PASS if _close(expected, actual, abs_tol, rel_tol) else FAIL
52
+ return RuleOutcome(rule_id, desc, statement, period, status, severity,
53
+ round(float(expected), 6), round(float(actual), 6),
54
+ round(float(actual) - float(expected), 6))
55
+
56
+
57
+ def _skip(rule_id, desc, statement, period, severity, message) -> RuleOutcome:
58
+ return RuleOutcome(rule_id, desc, statement, period, SKIP, severity,
59
+ message=message)
60
+
61
+
62
+ def _income_relations(out, cur, period, at, rt) -> None:
63
+ isd = cur["is"]
64
+ for target, plus, minus in IS_RELATIONS:
65
+ if target not in isd:
66
+ continue
67
+ if plus[0] not in isd:
68
+ out.append(_skip(f"FOOT.is.{target}", IS_DESC[target], "IS", period,
69
+ ERROR, f"{plus[0]} not provided"))
70
+ continue
71
+ expected = _sum(isd, plus) - _sum(isd, minus)
72
+ out.append(_check(f"FOOT.is.{target}", IS_DESC[target], "IS", period,
73
+ ERROR, expected, isd[target], at, rt))
74
+
75
+
76
+ def _bs_sections(out, cur, period, at, rt) -> None:
77
+ bs = cur["bs"]
78
+ for subtotal, items in BS_SECTIONS.items():
79
+ if subtotal not in bs:
80
+ continue
81
+ present = _present(bs, items)
82
+ if not present:
83
+ out.append(_skip(f"FOOT.bs.{subtotal}", BS_SECTION_DESC[subtotal], "BS",
84
+ period, ERROR, "no line items provided"))
85
+ continue
86
+ out.append(_check(f"FOOT.bs.{subtotal}", BS_SECTION_DESC[subtotal], "BS",
87
+ period, ERROR, _sum(bs, present), bs[subtotal], at, rt))
88
+
89
+
90
+ def _bs_totals(out, cur, period, at, rt) -> None:
91
+ bs = cur["bs"]
92
+ for total, parts in BS_TOTALS.items():
93
+ if total not in bs or not all(p in bs for p in parts):
94
+ continue
95
+ out.append(_check(f"FOOT.bs.{total}", BS_TOTAL_DESC[total], "BS", period,
96
+ ERROR, _sum(bs, parts), bs[total], at, rt))
97
+
98
+
99
+ def _accounting_equation(out, cur, period, at, rt) -> None:
100
+ bs = cur["bs"]
101
+ if not all(k in bs for k in ("total_assets", "total_liabilities", "total_equity")):
102
+ return
103
+ out.append(_check("EQ.accounting_equation",
104
+ "assets = liabilities + equity", "BS", period, ERROR,
105
+ bs["total_liabilities"] + bs["total_equity"],
106
+ bs["total_assets"], at, rt))
107
+
108
+
109
+ def _cf_sections(out, cur, period, at, rt) -> None:
110
+ cf = cur["cf"]
111
+ for subtotal, items in CF_SECTIONS.items():
112
+ if subtotal not in cf:
113
+ continue
114
+ present = _present(cf, items)
115
+ if not present:
116
+ out.append(_skip(f"FOOT.cf.{subtotal}", CF_SECTION_DESC[subtotal], "CF",
117
+ period, ERROR, "no line items provided"))
118
+ continue
119
+ out.append(_check(f"FOOT.cf.{subtotal}", CF_SECTION_DESC[subtotal], "CF",
120
+ period, ERROR, _sum(cf, present), cf[subtotal], at, rt))
121
+
122
+
123
+ def _cf_net_change(out, cur, period, at, rt) -> None:
124
+ cf = cur["cf"]
125
+ if not all(k in cf for k in ("cfo", "cfi", "cff", "net_change_in_cash")):
126
+ return
127
+ expected = cf["cfo"] + cf["cfi"] + cf["cff"] + cf.get("fx_effect", 0.0)
128
+ out.append(_check("FOOT.cf.net_change",
129
+ "net change in cash = cfo + cfi + cff", "CF", period, ERROR,
130
+ expected, cf["net_change_in_cash"], at, rt))
131
+
132
+
133
+ def _cash_ties(out, cur, prev, period, at, rt) -> None:
134
+ cf, bs = cur["cf"], cur["bs"]
135
+ if all(k in cf for k in ("net_change_in_cash", "beginning_cash", "ending_cash")):
136
+ out.append(_check("CASH.net_change_ties",
137
+ "net change = ending cash - beginning cash", "CF", period,
138
+ ERROR, cf["ending_cash"] - cf["beginning_cash"],
139
+ cf["net_change_in_cash"], at, rt))
140
+ if "ending_cash" in cf and "cash" in bs:
141
+ out.append(_check("CASH.ending_ties",
142
+ "cash flow ending cash = balance sheet cash", "BS/CF",
143
+ period, ERROR, bs["cash"], cf["ending_cash"], at, rt))
144
+ if prev is not None and "beginning_cash" in cf and "cash" in prev["bs"]:
145
+ out.append(_check("CASH.beginning_ties",
146
+ "beginning cash = prior period balance sheet cash", "BS/CF",
147
+ period, ERROR, prev["bs"]["cash"], cf["beginning_cash"],
148
+ at, rt))
149
+
150
+
151
+ def _articulation(out, cur, prev, period, at, rt) -> None:
152
+ isd, bs, cf = cur["is"], cur["bs"], cur["cf"]
153
+
154
+ if "net_income" in isd and "net_income" in cf:
155
+ out.append(_check("ART.net_income",
156
+ "net income agrees between income statement and cash flow",
157
+ "IS/CF", period, ERROR, isd["net_income"], cf["net_income"],
158
+ at, rt))
159
+
160
+ ni = isd.get("net_income", cf.get("net_income"))
161
+ if (prev is not None and "retained_earnings" in bs
162
+ and "retained_earnings" in prev["bs"] and ni is not None):
163
+ dividends = cf.get("dividends_paid", 0.0)
164
+ expected = prev["bs"]["retained_earnings"] + ni + dividends
165
+ out.append(_check("ART.retained_earnings",
166
+ "retained earnings = prior + net income - dividends", "BS",
167
+ period, ERROR, expected, bs["retained_earnings"], at, rt))
168
+
169
+ if "depreciation_amortization" in cf and "depreciation_amortization" in isd:
170
+ out.append(_check("ART.depreciation",
171
+ "depreciation agrees between income statement and cash flow",
172
+ "IS/CF", period, WARNING, isd["depreciation_amortization"],
173
+ cf["depreciation_amortization"], at, rt))
174
+
175
+
176
+ def run_rules(s: Statements, abs_tol: float, rel_tol: float) -> list[RuleOutcome]:
177
+ out: list[RuleOutcome] = []
178
+ for i, period in enumerate(s.periods):
179
+ cur = s.at(period)
180
+ prev = s.at(s.periods[i - 1]) if i > 0 else None
181
+ _income_relations(out, cur, period, abs_tol, rel_tol)
182
+ _bs_sections(out, cur, period, abs_tol, rel_tol)
183
+ _bs_totals(out, cur, period, abs_tol, rel_tol)
184
+ _accounting_equation(out, cur, period, abs_tol, rel_tol)
185
+ _cf_sections(out, cur, period, abs_tol, rel_tol)
186
+ _cf_net_change(out, cur, period, abs_tol, rel_tol)
187
+ _cash_ties(out, cur, prev, period, abs_tol, rel_tol)
188
+ _articulation(out, cur, prev, period, abs_tol, rel_tol)
189
+ return out
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: finvariant
3
+ Version: 0.1.0
4
+ Summary: Deterministic integrity checks for financial statements: does the balance sheet balance, does the cash flow tie out, do the three statements articulate. Verifies, does not parse or build.
5
+ Author: Atakan Arikan
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/arikanatakan/finvariant
8
+ Project-URL: Repository, https://github.com/arikanatakan/finvariant
9
+ Project-URL: Issues, https://github.com/arikanatakan/finvariant/issues
10
+ Keywords: accounting,financial-statements,audit,tie-out,integrity,balance-sheet,income-statement,cash-flow,three-statement-model,financial-modeling
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Financial and Insurance Industry
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7; extra == "dev"
25
+ Requires-Dist: ruff; extra == "dev"
26
+ Requires-Dist: build; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # finvariant
30
+
31
+ [![CI](https://github.com/arikanatakan/finvariant/actions/workflows/ci.yml/badge.svg)](https://github.com/arikanatakan/finvariant/actions/workflows/ci.yml)
32
+ [![PyPI](https://img.shields.io/pypi/v/finvariant)](https://pypi.org/project/finvariant/)
33
+ [![License: MIT](https://img.shields.io/github/license/arikanatakan/finvariant)](LICENSE)
34
+
35
+ Deterministic integrity checks for financial statements.
36
+
37
+ Give finvariant income statement, balance sheet and cash flow data; it verifies
38
+ the accounting invariants - the balance sheet balances, the cash flow ties to
39
+ the balance sheet, subtotals foot, and the three statements articulate - and
40
+ returns a structured, auditable report. It verifies; it does not parse, fetch
41
+ or build statements.
42
+
43
+ ## Motivation
44
+
45
+ Python has plenty of libraries to *retrieve* statements (financetoolkit, the SEC
46
+ tools) and to *build* models (DCF templates, FP&A scripts). What none of them do
47
+ is *check that a set of statements or a model is internally consistent*: that
48
+ assets equal liabilities plus equity, that the cash flow's ending cash matches
49
+ the balance sheet, that retained earnings roll forward by net income less
50
+ dividends, that every subtotal foots. That check is exactly what a spreadsheet
51
+ silently gets wrong - and surveys put an error in the large majority of business
52
+ spreadsheets.
53
+
54
+ finvariant encodes those invariants as deterministic, testable rules. The same
55
+ thing a large language model cannot be trusted to get right (consistent
56
+ arithmetic across linked statements), a small library can guarantee. Every
57
+ result is one report: a verdict, the exact failing checks with expected vs
58
+ actual, and provenance, so a verification can be reproduced and audited later.
59
+
60
+ ```
61
+ pip install finvariant
62
+ ```
63
+
64
+ No runtime dependencies.
65
+
66
+ ## Usage
67
+
68
+ Catch an error:
69
+
70
+ ```python
71
+ import finvariant as fv
72
+
73
+ s = fv.Statements(
74
+ periods=["FY2024"],
75
+ balance_sheet={"FY2024": {
76
+ "total_assets": 540, # should be 538
77
+ "total_liabilities": 158,
78
+ "total_equity": 380,
79
+ }},
80
+ )
81
+
82
+ r = fv.check(s)
83
+ r.ok # False
84
+ print(r.summary())
85
+ # finvariant audit - 2026-...
86
+ # 1 checks run, 0 passed, 1 failed, 0 skipped
87
+ # [ERROR] EQ.accounting_equation assets = liabilities + equity (FY2024): expected 538, got 540, off by 2
88
+ # Verdict: FAIL - statements do not tie out
89
+ ```
90
+
91
+ Real statements tie out (Apple FY2024, from the 10-K):
92
+
93
+ ```python
94
+ s = fv.Statements(
95
+ periods=["FY2024"],
96
+ income_statement={"FY2024": {
97
+ "revenue": 391035, "cogs": 210352, "gross_profit": 180683,
98
+ "operating_expenses": 57467, "operating_income": 123216,
99
+ "other_income": 269, "pretax_income": 123485, "tax": 29749,
100
+ "net_income": 93736,
101
+ }},
102
+ balance_sheet={"FY2024": {
103
+ "total_current_assets": 152987, "total_non_current_assets": 211993,
104
+ "total_assets": 364980,
105
+ "total_current_liabilities": 176392, "total_non_current_liabilities": 131638,
106
+ "total_liabilities": 308030,
107
+ "common_stock": 83276, "retained_earnings": -19154,
108
+ "accumulated_oci": -7172, "total_equity": 56950,
109
+ }},
110
+ )
111
+ fv.check(s).ok # True
112
+ ```
113
+
114
+ The report carries named findings, counts, `ok`, `summary()` and a JSON-safe
115
+ `to_dict()` with provenance (version, input hash, timestamp).
116
+
117
+ ## What it checks
118
+
119
+ | Group | Invariant |
120
+ |-------|-----------|
121
+ | Footing | every subtotal equals the sum of its line items (all three statements) |
122
+ | Equation | total assets = total liabilities + total equity |
123
+ | Cash | net change = cfo + cfi + cff; ending cash ties to the balance sheet; beginning cash ties to the prior period |
124
+ | Articulation | net income agrees across statements; retained earnings roll forward by net income less dividends |
125
+
126
+ Provide only the fields you have: a check whose inputs are missing is reported
127
+ as skipped, never failed. Tolerances absorb the rounding in statements reported
128
+ in whole millions.
129
+
130
+ ## Status
131
+
132
+ Version 0.1.0. Single entity, single currency, one or more periods, in a
133
+ canonical schema. The `Statements` input and `AuditReport` output are the
134
+ contract and are append-only from here.
135
+
136
+ ## Roadmap
137
+
138
+ | Version | Scope |
139
+ |---------|-------|
140
+ | 0.2 | roll-forward checks (PP&E = opening + capex - depreciation - disposals; debt; equity); working-capital changes reconciled to operating cash flow |
141
+ | 0.3 | an MCP server so an agent can verify financial statements it reads or generates |
142
+ | 0.4 | optional readers to map common export formats into the canonical schema |
143
+
144
+ Out of scope: retrieving statements (see financetoolkit, the SEC tools),
145
+ building or forecasting models, ratio analysis, consolidation and currency
146
+ translation.
147
+
148
+ ## License
149
+
150
+ MIT. Written and maintained by [Atakan Arikan](https://github.com/arikanatakan),
151
+ MSc Student at Tsinghua University and Politecnico di Milano.
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ finvariant/__init__.py
5
+ finvariant/_result.py
6
+ finvariant/_version.py
7
+ finvariant/check.py
8
+ finvariant/model.py
9
+ finvariant/py.typed
10
+ finvariant/rules.py
11
+ finvariant.egg-info/PKG-INFO
12
+ finvariant.egg-info/SOURCES.txt
13
+ finvariant.egg-info/dependency_links.txt
14
+ finvariant.egg-info/requires.txt
15
+ finvariant.egg-info/top_level.txt
16
+ tests/test_check.py
17
+ tests/test_model.py
18
+ tests/test_validation_suite.py
@@ -0,0 +1,5 @@
1
+
2
+ [dev]
3
+ pytest>=7
4
+ ruff
5
+ build
@@ -0,0 +1 @@
1
+ finvariant
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "finvariant"
7
+ version = "0.1.0"
8
+ description = "Deterministic integrity checks for financial statements: does the balance sheet balance, does the cash flow tie out, do the three statements articulate. Verifies, does not parse or build."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Atakan Arikan" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = []
14
+ keywords = [
15
+ "accounting", "financial-statements", "audit", "tie-out", "integrity",
16
+ "balance-sheet", "income-statement", "cash-flow", "three-statement-model",
17
+ "financial-modeling",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Financial and Insurance Industry",
22
+ "Intended Audience :: Developers",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Topic :: Office/Business :: Financial :: Accounting",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=7", "ruff", "build"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/arikanatakan/finvariant"
36
+ Repository = "https://github.com/arikanatakan/finvariant"
37
+ Issues = "https://github.com/arikanatakan/finvariant/issues"
38
+
39
+ [tool.setuptools.packages.find]
40
+ include = ["finvariant*"]
41
+
42
+ [tool.setuptools.package-data]
43
+ finvariant = ["py.typed"]
44
+
45
+ [tool.ruff]
46
+ line-length = 88
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,78 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ import finvariant as fv
6
+
7
+
8
+ def _balanced(assets=100, liabilities=60, equity=40):
9
+ return fv.Statements(
10
+ periods=["FY2024"],
11
+ balance_sheet={"FY2024": {
12
+ "total_assets": assets, "total_liabilities": liabilities,
13
+ "total_equity": equity,
14
+ }},
15
+ )
16
+
17
+
18
+ def test_equation_passes():
19
+ r = fv.check(_balanced())
20
+ assert r.ok
21
+ assert r.n_failed == 0
22
+ assert any(o.rule_id == "EQ.accounting_equation" and o.status == "pass"
23
+ for o in r.outcomes)
24
+
25
+
26
+ def test_equation_fails_with_numbers():
27
+ r = fv.check(_balanced(assets=101))
28
+ assert not r.ok
29
+ assert len(r.findings) == 1
30
+ f = r.findings[0]
31
+ assert f.rule_id == "EQ.accounting_equation"
32
+ assert f.expected == 100 and f.actual == 101 and f.difference == 1
33
+
34
+
35
+ def test_relative_tolerance_absorbs_rounding():
36
+ s = _balanced(assets=1000050, liabilities=1000000, equity=0)
37
+ assert fv.check(s).ok # 50 is within 0.01% of 1,000,000
38
+ assert not fv.check(s, rel_tol=0.0).ok # demanded exact, so it fails
39
+
40
+
41
+ def test_rules_filter():
42
+ r = fv.check(_balanced(), rules=["EQ.accounting_equation"])
43
+ assert {o.rule_id for o in r.outcomes} == {"EQ.accounting_equation"}
44
+
45
+
46
+ def test_subtotal_without_items_skips():
47
+ s = fv.Statements(periods=["FY2024"],
48
+ balance_sheet={"FY2024": {"total_current_assets": 100}})
49
+ r = fv.check(s)
50
+ assert any(o.rule_id == "FOOT.bs.total_current_assets" and o.status == "skip"
51
+ for o in r.outcomes)
52
+ assert r.ok # a skip never fails the report
53
+
54
+
55
+ def test_to_dict_is_json_serializable():
56
+ d = fv.check(_balanced()).to_dict()
57
+ json.dumps(d)
58
+ assert d["schema"] == 1
59
+ assert d["ok"] is True
60
+ assert "input_hash" in d["meta"]
61
+
62
+
63
+ def test_summary_is_plain_text():
64
+ t = fv.check(_balanced()).summary()
65
+ assert "finvariant" in t
66
+ assert "Verdict" in t
67
+ assert "—" not in t # no em dash
68
+
69
+
70
+ def test_provenance_changes_with_input():
71
+ a = fv.check(_balanced()).meta["input_hash"]
72
+ b = fv.check(_balanced(assets=200, liabilities=100, equity=100)).meta["input_hash"]
73
+ assert a != b
74
+
75
+
76
+ def test_typeerror_on_non_statements():
77
+ with pytest.raises(TypeError):
78
+ fv.check({"periods": ["FY2024"]})
@@ -0,0 +1,41 @@
1
+ import pytest
2
+
3
+ from finvariant import Statements
4
+
5
+
6
+ def test_unknown_field_raises():
7
+ with pytest.raises(ValueError):
8
+ Statements(periods=["FY2024"], balance_sheet={"FY2024": {"frobnicate": 1}})
9
+
10
+
11
+ def test_non_finite_raises():
12
+ with pytest.raises(ValueError):
13
+ Statements(periods=["FY2024"], balance_sheet={"FY2024": {"cash": float("nan")}})
14
+
15
+
16
+ def test_bool_rejected():
17
+ with pytest.raises(ValueError):
18
+ Statements(periods=["FY2024"], balance_sheet={"FY2024": {"cash": True}})
19
+
20
+
21
+ def test_empty_periods_raises():
22
+ with pytest.raises(ValueError):
23
+ Statements(periods=[])
24
+
25
+
26
+ def test_duplicate_periods_raise():
27
+ with pytest.raises(ValueError):
28
+ Statements(periods=["FY2024", "FY2024"])
29
+
30
+
31
+ def test_period_not_declared_raises():
32
+ with pytest.raises(ValueError):
33
+ Statements(periods=["FY2024"], balance_sheet={"FY2025": {"cash": 1}})
34
+
35
+
36
+ def test_at_returns_three_statements():
37
+ s = Statements(periods=["FY2024"], balance_sheet={"FY2024": {"cash": 10}})
38
+ at = s.at("FY2024")
39
+ assert set(at) == {"is", "bs", "cf"}
40
+ assert at["bs"]["cash"] == 10
41
+ assert at["is"] == {}
@@ -0,0 +1,30 @@
1
+ """Run finvariant against the cases in validation_cases.json: a hand-built
2
+ consistent model, Apple's real FY2024 statements, and one isolated breakage per
3
+ invariant.
4
+ """
5
+
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ import finvariant as fv
12
+
13
+ CASES = json.loads((Path(__file__).parent / "validation_cases.json").read_text())["cases"]
14
+
15
+
16
+ def _build(case):
17
+ return fv.Statements(
18
+ periods=case["periods"],
19
+ income_statement=case.get("income_statement", {}),
20
+ balance_sheet=case.get("balance_sheet", {}),
21
+ cash_flow=case.get("cash_flow", {}),
22
+ )
23
+
24
+
25
+ @pytest.mark.parametrize("case", CASES, ids=[c["id"] for c in CASES])
26
+ def test_case(case):
27
+ report = fv.check(_build(case))
28
+ assert report.ok is case["expect_ok"]
29
+ failed = {o.rule_id for o in report.findings}
30
+ assert failed == set(case["expect_failed_rules"])