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.
@@ -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
+ ![Corporate payments Benford](docs/figures/corporate_payment-benford.png)
205
+ ![Madoff exposure Benford](docs/figures/madoff_returns-benford.png)
206
+ ![Sino-Forest Benford](docs/figures/sino_forest-benford.png)
207
+
208
+ ### Synthetic demonstration
209
+
210
+ ![Synthetic JET counts](docs/figures/synthetic-jet-counts.png)
211
+ ![Synthetic entry risk](docs/figures/synthetic-entry-risk.png)
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
+ ![Corporate payments Benford](docs/figures/corporate_payment-benford.png)
163
+ ![Madoff exposure Benford](docs/figures/madoff_returns-benford.png)
164
+ ![Sino-Forest Benford](docs/figures/sino_forest-benford.png)
165
+
166
+ ### Synthetic demonstration
167
+
168
+ ![Synthetic JET counts](docs/figures/synthetic-jet-counts.png)
169
+ ![Synthetic entry risk](docs/figures/synthetic-entry-risk.png)
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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}%"