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.
- finvariant-0.1.0/LICENSE +21 -0
- finvariant-0.1.0/PKG-INFO +151 -0
- finvariant-0.1.0/README.md +123 -0
- finvariant-0.1.0/finvariant/__init__.py +28 -0
- finvariant-0.1.0/finvariant/_result.py +126 -0
- finvariant-0.1.0/finvariant/_version.py +1 -0
- finvariant-0.1.0/finvariant/check.py +55 -0
- finvariant-0.1.0/finvariant/model.py +145 -0
- finvariant-0.1.0/finvariant/py.typed +0 -0
- finvariant-0.1.0/finvariant/rules.py +189 -0
- finvariant-0.1.0/finvariant.egg-info/PKG-INFO +151 -0
- finvariant-0.1.0/finvariant.egg-info/SOURCES.txt +18 -0
- finvariant-0.1.0/finvariant.egg-info/dependency_links.txt +1 -0
- finvariant-0.1.0/finvariant.egg-info/requires.txt +5 -0
- finvariant-0.1.0/finvariant.egg-info/top_level.txt +1 -0
- finvariant-0.1.0/pyproject.toml +49 -0
- finvariant-0.1.0/setup.cfg +4 -0
- finvariant-0.1.0/tests/test_check.py +78 -0
- finvariant-0.1.0/tests/test_model.py +41 -0
- finvariant-0.1.0/tests/test_validation_suite.py +30 -0
finvariant-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](https://github.com/arikanatakan/finvariant/actions/workflows/ci.yml)
|
|
32
|
+
[](https://pypi.org/project/finvariant/)
|
|
33
|
+
[](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
|
+
[](https://github.com/arikanatakan/finvariant/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/finvariant/)
|
|
5
|
+
[](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
|
+
[](https://github.com/arikanatakan/finvariant/actions/workflows/ci.yml)
|
|
32
|
+
[](https://pypi.org/project/finvariant/)
|
|
33
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -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,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"])
|