ledgerlens 0.1.2__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.
- ledgerlens-0.1.2/LICENSE +21 -0
- ledgerlens-0.1.2/PKG-INFO +240 -0
- ledgerlens-0.1.2/README.md +198 -0
- ledgerlens-0.1.2/pyproject.toml +60 -0
- ledgerlens-0.1.2/setup.cfg +4 -0
- ledgerlens-0.1.2/src/ledgerlens/__init__.py +60 -0
- ledgerlens-0.1.2/src/ledgerlens/_utils.py +90 -0
- ledgerlens-0.1.2/src/ledgerlens/benford.py +518 -0
- ledgerlens-0.1.2/src/ledgerlens/cli.py +154 -0
- ledgerlens-0.1.2/src/ledgerlens/datasets.py +126 -0
- ledgerlens-0.1.2/src/ledgerlens/jet.py +357 -0
- ledgerlens-0.1.2/src/ledgerlens/report.py +179 -0
- ledgerlens-0.1.2/src/ledgerlens/scores.py +266 -0
- ledgerlens-0.1.2/src/ledgerlens.egg-info/PKG-INFO +240 -0
- ledgerlens-0.1.2/src/ledgerlens.egg-info/SOURCES.txt +24 -0
- ledgerlens-0.1.2/src/ledgerlens.egg-info/dependency_links.txt +1 -0
- ledgerlens-0.1.2/src/ledgerlens.egg-info/entry_points.txt +2 -0
- ledgerlens-0.1.2/src/ledgerlens.egg-info/requires.txt +25 -0
- ledgerlens-0.1.2/src/ledgerlens.egg-info/top_level.txt +1 -0
- ledgerlens-0.1.2/tests/test_benford.py +124 -0
- ledgerlens-0.1.2/tests/test_cli.py +48 -0
- ledgerlens-0.1.2/tests/test_datasets.py +63 -0
- ledgerlens-0.1.2/tests/test_jet.py +153 -0
- ledgerlens-0.1.2/tests/test_reference_r.py +33 -0
- ledgerlens-0.1.2/tests/test_report.py +103 -0
- ledgerlens-0.1.2/tests/test_scores.py +85 -0
ledgerlens-0.1.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yu Zonglin
|
|
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,240 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ledgerlens
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Forensic accounting analytics for Python: Benford's Law (Nigrini), journal entry testing, and Beneish / Altman / Piotroski scores.
|
|
5
|
+
Author: Yu Zonglin (Sam Yu)
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yuzonglinsam/ledgerlens
|
|
8
|
+
Project-URL: Issues, https://github.com/yuzonglinsam/ledgerlens/issues
|
|
9
|
+
Keywords: audit,forensic accounting,benford,journal entry testing,fraud detection,beneish,altman,piotroski,data analytics
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: numpy>=1.21
|
|
22
|
+
Requires-Dist: pandas>=1.3
|
|
23
|
+
Requires-Dist: scipy>=1.7
|
|
24
|
+
Requires-Dist: openpyxl>=3.0
|
|
25
|
+
Provides-Extra: excel
|
|
26
|
+
Requires-Dist: openpyxl>=3.0; extra == "excel"
|
|
27
|
+
Provides-Extra: plot
|
|
28
|
+
Requires-Dist: matplotlib>=3.4; extra == "plot"
|
|
29
|
+
Provides-Extra: reference
|
|
30
|
+
Requires-Dist: pyreadr>=0.4; extra == "reference"
|
|
31
|
+
Provides-Extra: all
|
|
32
|
+
Requires-Dist: openpyxl>=3.0; extra == "all"
|
|
33
|
+
Requires-Dist: matplotlib>=3.4; extra == "all"
|
|
34
|
+
Requires-Dist: pyreadr>=0.4; extra == "all"
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
37
|
+
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
38
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
39
|
+
Requires-Dist: openpyxl>=3.0; extra == "dev"
|
|
40
|
+
Requires-Dist: matplotlib>=3.4; extra == "dev"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# ledgerlens
|
|
44
|
+
|
|
45
|
+
**Forensic accounting analytics for Python.** Run Nigrini-style Benford tests, 13
|
|
46
|
+
explainable journal-entry procedures, Beneish / Altman / Piotroski financial screens,
|
|
47
|
+
and export auditor-readable Excel workpapers. MIT licensed; Python 3.9–3.13.
|
|
48
|
+
|
|
49
|
+
`ledgerlens` surfaces **red flags / reasons to investigate**. A flag is a sample-selection
|
|
50
|
+
signal, never a finding that fraud occurred.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install . # from a cloned repository; includes Excel support
|
|
56
|
+
pip install '.[dev]' # development, pytest, pytest-cov and ruff
|
|
57
|
+
pip install '.[reference,plot]' # optional R reference downloader and visualisations
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Download the verified [v0.1.2 wheel and source distribution](https://github.com/yuzonglinsam/ledgerlens/releases/tag/v0.1.2). The package is not on PyPI yet;
|
|
61
|
+
`pip install ledgerlens` will become available after PyPI Trusted Publishing is configured and the upload succeeds.
|
|
62
|
+
|
|
63
|
+
## Benford's Law
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import ledgerlens as ll
|
|
67
|
+
|
|
68
|
+
amounts = [119.20, 152.15, 203.00, 501.42, 509.10, 817.50]
|
|
69
|
+
result = ll.benford(amounts, test="first_two", sign="positive", min_value=None)
|
|
70
|
+
print(result.summary())
|
|
71
|
+
print(result.table.head())
|
|
72
|
+
print(result.suspects())
|
|
73
|
+
|
|
74
|
+
# Other methods: first, second, first_three, last_two, summation_test,
|
|
75
|
+
# number_duplication, second_order, mantissa_arc_test.
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
For meaningful conformity testing, use an appropriately sized population that spans
|
|
79
|
+
several orders of magnitude. The library reports MAD, significance statistics, and
|
|
80
|
+
record-level suspects, retaining source indexes.
|
|
81
|
+
|
|
82
|
+
## Journal entry testing (JET01–JET13)
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from ledgerlens.datasets import synthetic_gl
|
|
86
|
+
from ledgerlens.jet import JournalEntryTester
|
|
87
|
+
|
|
88
|
+
gl, truth = synthetic_gl(n_entries=2000, seed=0) # SYNTHETIC teaching data
|
|
89
|
+
jet = JournalEntryTester(
|
|
90
|
+
gl, entry_id="entry_id", date="post_date", amount="amount",
|
|
91
|
+
account="account", description="description", user="user",
|
|
92
|
+
effective_date="effective_date", period_end="2025-12-31",
|
|
93
|
+
)
|
|
94
|
+
report = jet.run_all(threshold=50000, limits=[50000])
|
|
95
|
+
print(report.summary().to_string(index=False))
|
|
96
|
+
print(report.entry_risk_scores().head(10))
|
|
97
|
+
print(report["JET09"].flagged[["entry_id", "description", "_reason"]].head())
|
|
98
|
+
report.to_excel("jet_report.xlsx")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Input rows represent GL *lines*. Debit amounts are positive and credits negative;
|
|
102
|
+
alternatively provide `debit="Debit"` and `credit="Credit"` instead of `amount=`.
|
|
103
|
+
Only entry identifier, posting date and an amount representation are mandatory.
|
|
104
|
+
Optional account, user and narrative columns enable more tests. The 13 rules cover
|
|
105
|
+
imbalance, weekends/holidays, round values, large values, amounts just below approval
|
|
106
|
+
limits, duplicates, cutoff, backdating, English/Chinese risk keywords, blanks, rare
|
|
107
|
+
accounts, rare users/off-hours and Benford excess-digit spikes. `run_all()` retains
|
|
108
|
+
skipped rules and their reasons. Each flagged row has its original index and `_reason`;
|
|
109
|
+
the supplied DataFrame is unchanged. Risk score = number of distinct rules hit.
|
|
110
|
+
|
|
111
|
+
The workbook has a linked **Summary**, sortable **Entry risk**, one tab per rule and a
|
|
112
|
+
**Benford** tab with a native Excel bar chart when JET13 runs.
|
|
113
|
+
|
|
114
|
+
## Financial scores
|
|
115
|
+
|
|
116
|
+
Single-company input uses a `dict` or `pandas.Series` and returns a dataclass;
|
|
117
|
+
company-year batches use a DataFrame and return a DataFrame. All numbers in these
|
|
118
|
+
examples are *synthetic*.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from ledgerlens import beneish_m, altman_z, piotroski_f
|
|
122
|
+
|
|
123
|
+
altman_inputs = dict(
|
|
124
|
+
current_assets=40, current_liabilities=20, total_assets=100,
|
|
125
|
+
retained_earnings=10, ebit=10, total_liabilities=50,
|
|
126
|
+
market_cap=50, book_equity=50, sales=200,
|
|
127
|
+
)
|
|
128
|
+
print(altman_z(altman_inputs, model="public").summary())
|
|
129
|
+
print(altman_z(altman_inputs, model="private").summary())
|
|
130
|
+
print(altman_z(altman_inputs, model="non_manufacturing").summary())
|
|
131
|
+
|
|
132
|
+
beneish_fields = dict(
|
|
133
|
+
receivables=(20, 10), sales=(200, 100), cogs=(100, 50),
|
|
134
|
+
current_assets=(60, 30), ppe=(60, 30), total_assets=(200, 100),
|
|
135
|
+
depreciation=(12, 6), sga=(20, 10), current_liabilities=(40, 20),
|
|
136
|
+
long_term_debt=(60, 30),
|
|
137
|
+
)
|
|
138
|
+
beneish_inputs = {f"{name}_{period}": value
|
|
139
|
+
for name, pair in beneish_fields.items()
|
|
140
|
+
for period, value in zip(("t", "prev"), pair)}
|
|
141
|
+
beneish_inputs.update(net_income_t=20, cfo_t=10)
|
|
142
|
+
print(beneish_m(beneish_inputs).summary()) # 8-variable M8 and 5-variable M5
|
|
143
|
+
|
|
144
|
+
piotroski_inputs = dict(
|
|
145
|
+
net_income_t=15, net_income_prev=5, cfo_t=20, cfo_prev=2,
|
|
146
|
+
total_assets_t=100, total_assets_prev=100,
|
|
147
|
+
long_term_debt_t=10, long_term_debt_prev=20,
|
|
148
|
+
current_assets_t=60, current_assets_prev=40,
|
|
149
|
+
current_liabilities_t=20, current_liabilities_prev=20,
|
|
150
|
+
shares_outstanding_t=100, shares_outstanding_prev=100,
|
|
151
|
+
sales_t=200, sales_prev=100, cogs_t=80, cogs_prev=50,
|
|
152
|
+
)
|
|
153
|
+
print(piotroski_f(piotroski_inputs).summary()) # F-Score: 0–9
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Input names and published coefficients are documented in
|
|
157
|
+
[methodology](docs/methodology.md); ratios with zero denominators become NaN.
|
|
158
|
+
|
|
159
|
+
## Command line
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
ledgerlens demo -o demo.xlsx
|
|
163
|
+
ledgerlens benford payments.csv --column Amount --test first_two -o benford.xlsx
|
|
164
|
+
ledgerlens jet general_ledger.csv --entry-id JE --date PostDate --amount Amt \
|
|
165
|
+
--account Account --description Description --user User --period-end 2025-12-31 \
|
|
166
|
+
--threshold 100000 --limits 50000,100000 -o audit.xlsx
|
|
167
|
+
ledgerlens jet gl.xlsx --entry-id JE --date Date --debit Dr --credit Cr -o audit.xlsx
|
|
168
|
+
ledgerlens score beneish company_years.csv -o beneish.csv
|
|
169
|
+
ledgerlens score altman company_years.csv --model private -o altman.csv
|
|
170
|
+
ledgerlens score piotroski company_years.csv -o fscore.csv
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
CSV, XLSX and Parquet inputs are supported (Parquet requires an installed pandas
|
|
174
|
+
Parquet engine). Successful commands exit 0; expected input/usage errors exit 2.
|
|
175
|
+
`ledgerlens demo` generates its own synthetic GL and never fetches client data.
|
|
176
|
+
|
|
177
|
+
## Reproducible validation (2026-11-10)
|
|
178
|
+
|
|
179
|
+
| Check | Independently measured result | Method |
|
|
180
|
+
|---|---:|---|
|
|
181
|
+
| CI matrix (3.9 / 3.11 / 3.13) | **39 passed, 1 skipped** on each version | GitHub Actions: lint + coverage |
|
|
182
|
+
| Line coverage | **~95%** | Regression suite, including Excel and timezone edge cases |
|
|
183
|
+
| Fresh installation | **PASS** | Isolated GitHub Actions venv; `pip install .` then `ledgerlens demo -o demo.xlsx` from /tmp; 16-sheet workbook |
|
|
184
|
+
| Synthetic JET recall | **112/112 = 100.00%** | `synthetic_gl(2000, seed=0)`; matching `(entry_id, test_id)` pairs |
|
|
185
|
+
| Clean-entry false-positive rate | **390/2000 = 19.50%** | Clean entries flagged by at least one rule |
|
|
186
|
+
| Clean-entry-test false-positive rate | **390/26000 = 1.50%** | Clean `(entry_id, test_id)` pairs flagged |
|
|
187
|
+
| 1,000,000 GL lines / 13 JET rules | **8.73 seconds** | Python 3.13.5, pandas 2.2.3; benchmarked separately in a local container |
|
|
188
|
+
| R corporate-payment first-two MAD | **0.002336614** (matches 9 decimal places) | Independent GitHub Actions network run, 185,083 eligible observations |
|
|
189
|
+
| R corporate-payment distortion factor | **−1.065467% (R)** vs **−1.064985% (corrected)** | Same public dataset, intentional denominator correction |
|
|
190
|
+
|
|
191
|
+
The R reference test now runs in CI on `push`; locally it is opt-in using
|
|
192
|
+
`LEDGERLENS_NETWORK=1 pytest -m network -q` after
|
|
193
|
+
`pip install '.[reference]'`. Third-party `.rda` files are downloaded and
|
|
194
|
+
cached at runtime, never bundled or committed.
|
|
195
|
+
|
|
196
|
+
### Reproduced public case studies
|
|
197
|
+
|
|
198
|
+
| Public reference dataset | Eligible observations | First-two MAD | Conformity |
|
|
199
|
+
|---|---:|---:|---|
|
|
200
|
+
| Corporate payments | 185,083 | 0.002336614 | Nonconformity |
|
|
201
|
+
| Madoff *exposure amounts* | 198 | 0.006075249 | Nonconformity |
|
|
202
|
+
| Sino-Forest published figures | 772 | 0.003463887 | Nonconformity |
|
|
203
|
+
|
|
204
|
+

|
|
205
|
+

|
|
206
|
+

|
|
207
|
+
|
|
208
|
+
### Synthetic demonstration
|
|
209
|
+
|
|
210
|
+

|
|
211
|
+

|
|
212
|
+
|
|
213
|
+
Reproduce case studies with `python examples/case_studies.py --public` (requires
|
|
214
|
+
reference and plot extras). Generate the performance sample with
|
|
215
|
+
`python examples/benchmark_jet.py --lines 1000000`. Interpret the synthetic
|
|
216
|
+
false-positive rates as a controlled teaching example, not field precision.
|
|
217
|
+
See [public and synthetic case studies](docs/case-studies.md),
|
|
218
|
+
[methodology](docs/methodology.md), [performance](docs/performance.md), and
|
|
219
|
+
[decisions](docs/decisions.md).
|
|
220
|
+
|
|
221
|
+
## Development and release
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
python -m pytest -q
|
|
225
|
+
python -m pytest --cov=ledgerlens --cov-fail-under=90 -q
|
|
226
|
+
ruff check .
|
|
227
|
+
python -m build
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
CI runs Python 3.9, 3.11 and 3.13, also checking fresh installation,
|
|
231
|
+
benchmark reproducibility and external R references.
|
|
232
|
+
The [GitHub v0.1.2 release](https://github.com/yuzonglinsam/ledgerlens/releases/tag/v0.1.2) includes a wheel and sdist.
|
|
233
|
+
A [manual OIDC publishing workflow](docs/publishing.md) is ready for TestPyPI and PyPI; the repository owner must register the Trusted Publisher and explicitly initiate publication.
|
|
234
|
+
|
|
235
|
+
## Disclaimer and licence
|
|
236
|
+
|
|
237
|
+
These methods provide statistical indicators and audit sampling leads; conclusions
|
|
238
|
+
require professional investigation, appropriate population selection, and corroborating
|
|
239
|
+
evidence. Do not treat any output as an automated accusation of wrongdoing.
|
|
240
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# ledgerlens
|
|
2
|
+
|
|
3
|
+
**Forensic accounting analytics for Python.** Run Nigrini-style Benford tests, 13
|
|
4
|
+
explainable journal-entry procedures, Beneish / Altman / Piotroski financial screens,
|
|
5
|
+
and export auditor-readable Excel workpapers. MIT licensed; Python 3.9–3.13.
|
|
6
|
+
|
|
7
|
+
`ledgerlens` surfaces **red flags / reasons to investigate**. A flag is a sample-selection
|
|
8
|
+
signal, never a finding that fraud occurred.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install . # from a cloned repository; includes Excel support
|
|
14
|
+
pip install '.[dev]' # development, pytest, pytest-cov and ruff
|
|
15
|
+
pip install '.[reference,plot]' # optional R reference downloader and visualisations
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Download the verified [v0.1.2 wheel and source distribution](https://github.com/yuzonglinsam/ledgerlens/releases/tag/v0.1.2). The package is not on PyPI yet;
|
|
19
|
+
`pip install ledgerlens` will become available after PyPI Trusted Publishing is configured and the upload succeeds.
|
|
20
|
+
|
|
21
|
+
## Benford's Law
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import ledgerlens as ll
|
|
25
|
+
|
|
26
|
+
amounts = [119.20, 152.15, 203.00, 501.42, 509.10, 817.50]
|
|
27
|
+
result = ll.benford(amounts, test="first_two", sign="positive", min_value=None)
|
|
28
|
+
print(result.summary())
|
|
29
|
+
print(result.table.head())
|
|
30
|
+
print(result.suspects())
|
|
31
|
+
|
|
32
|
+
# Other methods: first, second, first_three, last_two, summation_test,
|
|
33
|
+
# number_duplication, second_order, mantissa_arc_test.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For meaningful conformity testing, use an appropriately sized population that spans
|
|
37
|
+
several orders of magnitude. The library reports MAD, significance statistics, and
|
|
38
|
+
record-level suspects, retaining source indexes.
|
|
39
|
+
|
|
40
|
+
## Journal entry testing (JET01–JET13)
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from ledgerlens.datasets import synthetic_gl
|
|
44
|
+
from ledgerlens.jet import JournalEntryTester
|
|
45
|
+
|
|
46
|
+
gl, truth = synthetic_gl(n_entries=2000, seed=0) # SYNTHETIC teaching data
|
|
47
|
+
jet = JournalEntryTester(
|
|
48
|
+
gl, entry_id="entry_id", date="post_date", amount="amount",
|
|
49
|
+
account="account", description="description", user="user",
|
|
50
|
+
effective_date="effective_date", period_end="2025-12-31",
|
|
51
|
+
)
|
|
52
|
+
report = jet.run_all(threshold=50000, limits=[50000])
|
|
53
|
+
print(report.summary().to_string(index=False))
|
|
54
|
+
print(report.entry_risk_scores().head(10))
|
|
55
|
+
print(report["JET09"].flagged[["entry_id", "description", "_reason"]].head())
|
|
56
|
+
report.to_excel("jet_report.xlsx")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Input rows represent GL *lines*. Debit amounts are positive and credits negative;
|
|
60
|
+
alternatively provide `debit="Debit"` and `credit="Credit"` instead of `amount=`.
|
|
61
|
+
Only entry identifier, posting date and an amount representation are mandatory.
|
|
62
|
+
Optional account, user and narrative columns enable more tests. The 13 rules cover
|
|
63
|
+
imbalance, weekends/holidays, round values, large values, amounts just below approval
|
|
64
|
+
limits, duplicates, cutoff, backdating, English/Chinese risk keywords, blanks, rare
|
|
65
|
+
accounts, rare users/off-hours and Benford excess-digit spikes. `run_all()` retains
|
|
66
|
+
skipped rules and their reasons. Each flagged row has its original index and `_reason`;
|
|
67
|
+
the supplied DataFrame is unchanged. Risk score = number of distinct rules hit.
|
|
68
|
+
|
|
69
|
+
The workbook has a linked **Summary**, sortable **Entry risk**, one tab per rule and a
|
|
70
|
+
**Benford** tab with a native Excel bar chart when JET13 runs.
|
|
71
|
+
|
|
72
|
+
## Financial scores
|
|
73
|
+
|
|
74
|
+
Single-company input uses a `dict` or `pandas.Series` and returns a dataclass;
|
|
75
|
+
company-year batches use a DataFrame and return a DataFrame. All numbers in these
|
|
76
|
+
examples are *synthetic*.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from ledgerlens import beneish_m, altman_z, piotroski_f
|
|
80
|
+
|
|
81
|
+
altman_inputs = dict(
|
|
82
|
+
current_assets=40, current_liabilities=20, total_assets=100,
|
|
83
|
+
retained_earnings=10, ebit=10, total_liabilities=50,
|
|
84
|
+
market_cap=50, book_equity=50, sales=200,
|
|
85
|
+
)
|
|
86
|
+
print(altman_z(altman_inputs, model="public").summary())
|
|
87
|
+
print(altman_z(altman_inputs, model="private").summary())
|
|
88
|
+
print(altman_z(altman_inputs, model="non_manufacturing").summary())
|
|
89
|
+
|
|
90
|
+
beneish_fields = dict(
|
|
91
|
+
receivables=(20, 10), sales=(200, 100), cogs=(100, 50),
|
|
92
|
+
current_assets=(60, 30), ppe=(60, 30), total_assets=(200, 100),
|
|
93
|
+
depreciation=(12, 6), sga=(20, 10), current_liabilities=(40, 20),
|
|
94
|
+
long_term_debt=(60, 30),
|
|
95
|
+
)
|
|
96
|
+
beneish_inputs = {f"{name}_{period}": value
|
|
97
|
+
for name, pair in beneish_fields.items()
|
|
98
|
+
for period, value in zip(("t", "prev"), pair)}
|
|
99
|
+
beneish_inputs.update(net_income_t=20, cfo_t=10)
|
|
100
|
+
print(beneish_m(beneish_inputs).summary()) # 8-variable M8 and 5-variable M5
|
|
101
|
+
|
|
102
|
+
piotroski_inputs = dict(
|
|
103
|
+
net_income_t=15, net_income_prev=5, cfo_t=20, cfo_prev=2,
|
|
104
|
+
total_assets_t=100, total_assets_prev=100,
|
|
105
|
+
long_term_debt_t=10, long_term_debt_prev=20,
|
|
106
|
+
current_assets_t=60, current_assets_prev=40,
|
|
107
|
+
current_liabilities_t=20, current_liabilities_prev=20,
|
|
108
|
+
shares_outstanding_t=100, shares_outstanding_prev=100,
|
|
109
|
+
sales_t=200, sales_prev=100, cogs_t=80, cogs_prev=50,
|
|
110
|
+
)
|
|
111
|
+
print(piotroski_f(piotroski_inputs).summary()) # F-Score: 0–9
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Input names and published coefficients are documented in
|
|
115
|
+
[methodology](docs/methodology.md); ratios with zero denominators become NaN.
|
|
116
|
+
|
|
117
|
+
## Command line
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
ledgerlens demo -o demo.xlsx
|
|
121
|
+
ledgerlens benford payments.csv --column Amount --test first_two -o benford.xlsx
|
|
122
|
+
ledgerlens jet general_ledger.csv --entry-id JE --date PostDate --amount Amt \
|
|
123
|
+
--account Account --description Description --user User --period-end 2025-12-31 \
|
|
124
|
+
--threshold 100000 --limits 50000,100000 -o audit.xlsx
|
|
125
|
+
ledgerlens jet gl.xlsx --entry-id JE --date Date --debit Dr --credit Cr -o audit.xlsx
|
|
126
|
+
ledgerlens score beneish company_years.csv -o beneish.csv
|
|
127
|
+
ledgerlens score altman company_years.csv --model private -o altman.csv
|
|
128
|
+
ledgerlens score piotroski company_years.csv -o fscore.csv
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
CSV, XLSX and Parquet inputs are supported (Parquet requires an installed pandas
|
|
132
|
+
Parquet engine). Successful commands exit 0; expected input/usage errors exit 2.
|
|
133
|
+
`ledgerlens demo` generates its own synthetic GL and never fetches client data.
|
|
134
|
+
|
|
135
|
+
## Reproducible validation (2026-11-10)
|
|
136
|
+
|
|
137
|
+
| Check | Independently measured result | Method |
|
|
138
|
+
|---|---:|---|
|
|
139
|
+
| CI matrix (3.9 / 3.11 / 3.13) | **39 passed, 1 skipped** on each version | GitHub Actions: lint + coverage |
|
|
140
|
+
| Line coverage | **~95%** | Regression suite, including Excel and timezone edge cases |
|
|
141
|
+
| Fresh installation | **PASS** | Isolated GitHub Actions venv; `pip install .` then `ledgerlens demo -o demo.xlsx` from /tmp; 16-sheet workbook |
|
|
142
|
+
| Synthetic JET recall | **112/112 = 100.00%** | `synthetic_gl(2000, seed=0)`; matching `(entry_id, test_id)` pairs |
|
|
143
|
+
| Clean-entry false-positive rate | **390/2000 = 19.50%** | Clean entries flagged by at least one rule |
|
|
144
|
+
| Clean-entry-test false-positive rate | **390/26000 = 1.50%** | Clean `(entry_id, test_id)` pairs flagged |
|
|
145
|
+
| 1,000,000 GL lines / 13 JET rules | **8.73 seconds** | Python 3.13.5, pandas 2.2.3; benchmarked separately in a local container |
|
|
146
|
+
| R corporate-payment first-two MAD | **0.002336614** (matches 9 decimal places) | Independent GitHub Actions network run, 185,083 eligible observations |
|
|
147
|
+
| R corporate-payment distortion factor | **−1.065467% (R)** vs **−1.064985% (corrected)** | Same public dataset, intentional denominator correction |
|
|
148
|
+
|
|
149
|
+
The R reference test now runs in CI on `push`; locally it is opt-in using
|
|
150
|
+
`LEDGERLENS_NETWORK=1 pytest -m network -q` after
|
|
151
|
+
`pip install '.[reference]'`. Third-party `.rda` files are downloaded and
|
|
152
|
+
cached at runtime, never bundled or committed.
|
|
153
|
+
|
|
154
|
+
### Reproduced public case studies
|
|
155
|
+
|
|
156
|
+
| Public reference dataset | Eligible observations | First-two MAD | Conformity |
|
|
157
|
+
|---|---:|---:|---|
|
|
158
|
+
| Corporate payments | 185,083 | 0.002336614 | Nonconformity |
|
|
159
|
+
| Madoff *exposure amounts* | 198 | 0.006075249 | Nonconformity |
|
|
160
|
+
| Sino-Forest published figures | 772 | 0.003463887 | Nonconformity |
|
|
161
|
+
|
|
162
|
+

|
|
163
|
+

|
|
164
|
+

|
|
165
|
+
|
|
166
|
+
### Synthetic demonstration
|
|
167
|
+
|
|
168
|
+

|
|
169
|
+

|
|
170
|
+
|
|
171
|
+
Reproduce case studies with `python examples/case_studies.py --public` (requires
|
|
172
|
+
reference and plot extras). Generate the performance sample with
|
|
173
|
+
`python examples/benchmark_jet.py --lines 1000000`. Interpret the synthetic
|
|
174
|
+
false-positive rates as a controlled teaching example, not field precision.
|
|
175
|
+
See [public and synthetic case studies](docs/case-studies.md),
|
|
176
|
+
[methodology](docs/methodology.md), [performance](docs/performance.md), and
|
|
177
|
+
[decisions](docs/decisions.md).
|
|
178
|
+
|
|
179
|
+
## Development and release
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
python -m pytest -q
|
|
183
|
+
python -m pytest --cov=ledgerlens --cov-fail-under=90 -q
|
|
184
|
+
ruff check .
|
|
185
|
+
python -m build
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
CI runs Python 3.9, 3.11 and 3.13, also checking fresh installation,
|
|
189
|
+
benchmark reproducibility and external R references.
|
|
190
|
+
The [GitHub v0.1.2 release](https://github.com/yuzonglinsam/ledgerlens/releases/tag/v0.1.2) includes a wheel and sdist.
|
|
191
|
+
A [manual OIDC publishing workflow](docs/publishing.md) is ready for TestPyPI and PyPI; the repository owner must register the Trusted Publisher and explicitly initiate publication.
|
|
192
|
+
|
|
193
|
+
## Disclaimer and licence
|
|
194
|
+
|
|
195
|
+
These methods provide statistical indicators and audit sampling leads; conclusions
|
|
196
|
+
require professional investigation, appropriate population selection, and corroborating
|
|
197
|
+
evidence. Do not treat any output as an automated accusation of wrongdoing.
|
|
198
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ledgerlens"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Forensic accounting analytics for Python: Benford's Law (Nigrini), journal entry testing, and Beneish / Altman / Piotroski scores."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Yu Zonglin (Sam Yu)" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"audit", "forensic accounting", "benford", "journal entry testing",
|
|
15
|
+
"fraud detection", "beneish", "altman", "piotroski", "data analytics",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"numpy>=1.21",
|
|
29
|
+
"pandas>=1.3",
|
|
30
|
+
"scipy>=1.7",
|
|
31
|
+
"openpyxl>=3.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
excel = ["openpyxl>=3.0"]
|
|
36
|
+
plot = ["matplotlib>=3.4"]
|
|
37
|
+
reference = ["pyreadr>=0.4"]
|
|
38
|
+
all = ["openpyxl>=3.0", "matplotlib>=3.4", "pyreadr>=0.4"]
|
|
39
|
+
dev = ["pytest>=7", "pytest-cov>=4", "ruff>=0.6", "openpyxl>=3.0", "matplotlib>=3.4"]
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
ledgerlens = "ledgerlens.cli:main"
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/yuzonglinsam/ledgerlens"
|
|
46
|
+
Issues = "https://github.com/yuzonglinsam/ledgerlens/issues"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["src"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
markers = ["network: tests that download public reference data at runtime"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 100
|
|
57
|
+
target-version = "py39"
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
select = ["E4", "E7", "E9", "F", "I"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""ledgerlens - forensic accounting analytics for Python.
|
|
2
|
+
|
|
3
|
+
Three building blocks:
|
|
4
|
+
|
|
5
|
+
* :mod:`ledgerlens.benford` - Benford's Law digit tests following Nigrini (2012)
|
|
6
|
+
* :mod:`ledgerlens.jet` - journal entry tests for general-ledger data
|
|
7
|
+
* :mod:`ledgerlens.scores` - Beneish M-Score, Altman Z-Score, Piotroski F-Score
|
|
8
|
+
|
|
9
|
+
Quick start::
|
|
10
|
+
|
|
11
|
+
import ledgerlens as ll
|
|
12
|
+
|
|
13
|
+
res = ll.benford(df["Amount"], test="first_two")
|
|
14
|
+
print(res.summary())
|
|
15
|
+
|
|
16
|
+
report = ll.JournalEntryTester(gl, entry_id="je_id", date="post_date",
|
|
17
|
+
amount="amount", account="account").run_all()
|
|
18
|
+
report.to_excel("jet_report.xlsx")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .benford import (
|
|
22
|
+
BenfordResult,
|
|
23
|
+
benford,
|
|
24
|
+
expected_distribution,
|
|
25
|
+
mantissa_arc_test,
|
|
26
|
+
number_duplication,
|
|
27
|
+
second_order,
|
|
28
|
+
summation_test,
|
|
29
|
+
)
|
|
30
|
+
from .jet import JETReport, JournalEntryTester, TestResult
|
|
31
|
+
from .scores import (
|
|
32
|
+
AltmanResult,
|
|
33
|
+
BeneishResult,
|
|
34
|
+
PiotroskiResult,
|
|
35
|
+
altman_z,
|
|
36
|
+
beneish_m,
|
|
37
|
+
piotroski_f,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.2"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"AltmanResult",
|
|
44
|
+
"BeneishResult",
|
|
45
|
+
"BenfordResult",
|
|
46
|
+
"JETReport",
|
|
47
|
+
"JournalEntryTester",
|
|
48
|
+
"PiotroskiResult",
|
|
49
|
+
"TestResult",
|
|
50
|
+
"altman_z",
|
|
51
|
+
"benford",
|
|
52
|
+
"beneish_m",
|
|
53
|
+
"expected_distribution",
|
|
54
|
+
"mantissa_arc_test",
|
|
55
|
+
"number_duplication",
|
|
56
|
+
"piotroski_f",
|
|
57
|
+
"second_order",
|
|
58
|
+
"summation_test",
|
|
59
|
+
"__version__",
|
|
60
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Internal helpers shared across modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
# Relative nudge used when extracting digits. Binary floating point cannot
|
|
11
|
+
# represent most decimals exactly (0.29 / 0.1 == 2.8999999999999995), which
|
|
12
|
+
# would make naive truncation report 28 instead of 29 as the first-two digits.
|
|
13
|
+
# Accounting amounts rarely carry more than ~12 significant digits, so a
|
|
14
|
+
# 1e-12 relative nudge fixes representation error without changing any value
|
|
15
|
+
# a human would have typed.
|
|
16
|
+
_EPS = 1e-12
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def clean_numeric(
|
|
20
|
+
data, sign: str = "positive"
|
|
21
|
+
) -> Tuple[np.ndarray, pd.Index]:
|
|
22
|
+
"""Return (positive magnitudes, original index) after the sign filter.
|
|
23
|
+
|
|
24
|
+
``sign`` follows the usual Benford-software convention:
|
|
25
|
+
|
|
26
|
+
* ``"positive"`` - keep values > 0 (default; recommended by Nigrini)
|
|
27
|
+
* ``"negative"`` - keep values < 0, analysed as magnitudes
|
|
28
|
+
* ``"both"`` - keep every non-zero value, analysed as magnitudes
|
|
29
|
+
"""
|
|
30
|
+
if isinstance(data, pd.DataFrame):
|
|
31
|
+
raise TypeError("Pass a single column (Series), not a DataFrame.")
|
|
32
|
+
s = data if isinstance(data, pd.Series) else pd.Series(np.asarray(data).ravel())
|
|
33
|
+
s = pd.to_numeric(s, errors="coerce").astype(float)
|
|
34
|
+
s = s[np.isfinite(s.to_numpy())]
|
|
35
|
+
|
|
36
|
+
if sign == "positive":
|
|
37
|
+
s = s[s > 0]
|
|
38
|
+
elif sign == "negative":
|
|
39
|
+
s = -s[s < 0]
|
|
40
|
+
elif sign == "both":
|
|
41
|
+
s = s[s != 0].abs()
|
|
42
|
+
else:
|
|
43
|
+
raise ValueError("sign must be 'positive', 'negative' or 'both'")
|
|
44
|
+
return s.to_numpy(dtype=float), s.index
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def leading_digits(x: np.ndarray, n: int) -> np.ndarray:
|
|
48
|
+
"""First ``n`` significant digits of each positive value, as integers.
|
|
49
|
+
|
|
50
|
+
``leading_digits([1234.5, 0.029], 2) -> [12, 29]``
|
|
51
|
+
"""
|
|
52
|
+
x = np.asarray(x, dtype=float)
|
|
53
|
+
if x.size == 0:
|
|
54
|
+
return np.array([], dtype=np.int64)
|
|
55
|
+
if np.any(x <= 0):
|
|
56
|
+
raise ValueError("leading_digits expects strictly positive values")
|
|
57
|
+
e = np.floor(np.log10(x))
|
|
58
|
+
s = x / np.power(10.0, e)
|
|
59
|
+
# log10 can land a hair on the wrong side of an integer for exact powers.
|
|
60
|
+
hi = s >= 10
|
|
61
|
+
s[hi] /= 10
|
|
62
|
+
lo = s < 1
|
|
63
|
+
s[lo] *= 10
|
|
64
|
+
d = np.floor(s * 10 ** (n - 1) * (1 + _EPS)).astype(np.int64)
|
|
65
|
+
# 9.999999999999998 nudged to 10.000000000001 really means 10**(e+1).
|
|
66
|
+
d[d >= 10**n] = 10 ** (n - 1)
|
|
67
|
+
return d
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def last_two_digits(x: np.ndarray, decimals: int = 0) -> Tuple[np.ndarray, np.ndarray]:
|
|
71
|
+
"""Last two digits of each value after scaling by ``10**decimals``.
|
|
72
|
+
|
|
73
|
+
Returns ``(digits, mask)`` where ``mask`` marks values whose scaled
|
|
74
|
+
integer is at least 10 (a one-digit number has no "last two digits").
|
|
75
|
+
"""
|
|
76
|
+
scaled = np.rint(np.asarray(x, dtype=float) * 10**decimals)
|
|
77
|
+
mask = scaled >= 10
|
|
78
|
+
digits = np.mod(scaled[mask], 100).astype(np.int64)
|
|
79
|
+
return digits, mask
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def require_columns(df: pd.DataFrame, cols: dict) -> None:
|
|
83
|
+
missing = {k: v for k, v in cols.items() if v is not None and v not in df.columns}
|
|
84
|
+
if missing:
|
|
85
|
+
detail = ", ".join(f"{k}={v!r}" for k, v in missing.items())
|
|
86
|
+
raise KeyError(f"Column(s) not found in data: {detail}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fmt_pct(x: Optional[float], digits: int = 1) -> str:
|
|
90
|
+
return "n/a" if x is None or not np.isfinite(x) else f"{x * 100:.{digits}f}%"
|