keptwise 1.2.1__py3-none-any.whl
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.
- keptwise/__init__.py +0 -0
- keptwise/__main__.py +3 -0
- keptwise/categories.py +60 -0
- keptwise/cli.py +251 -0
- keptwise/dates.py +11 -0
- keptwise/db.py +212 -0
- keptwise/demo.py +128 -0
- keptwise/importer.py +332 -0
- keptwise/insights.py +272 -0
- keptwise/investments.py +183 -0
- keptwise/money.py +58 -0
- keptwise/reports.py +459 -0
- keptwise/rules.py +150 -0
- keptwise/seed.py +252 -0
- keptwise/web/__init__.py +130 -0
- keptwise/web/charts.py +157 -0
- keptwise/web/static/app.css +531 -0
- keptwise/web/static/app.js +245 -0
- keptwise/web/templates/_platforms.html +34 -0
- keptwise/web/templates/base.html +53 -0
- keptwise/web/templates/import.html +103 -0
- keptwise/web/templates/investments.html +95 -0
- keptwise/web/templates/month.html +196 -0
- keptwise/web/templates/rules.html +170 -0
- keptwise/web/templates/settings.html +38 -0
- keptwise/web/templates/setup.html +28 -0
- keptwise/web/templates/tax_year.html +106 -0
- keptwise/web/templates/transactions.html +129 -0
- keptwise/web/templates/welcome.html +9 -0
- keptwise/web/views.py +911 -0
- keptwise-1.2.1.dist-info/METADATA +229 -0
- keptwise-1.2.1.dist-info/RECORD +35 -0
- keptwise-1.2.1.dist-info/WHEEL +4 -0
- keptwise-1.2.1.dist-info/entry_points.txt +3 -0
- keptwise-1.2.1.dist-info/licenses/LICENSE +674 -0
keptwise/importer.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Reading bank CSV exports and loading them into the database.
|
|
2
|
+
|
|
3
|
+
The importer recognises the column names UK banks commonly use, so most exports work
|
|
4
|
+
as they are:
|
|
5
|
+
|
|
6
|
+
* Lloyds, Halifax, Bank of Scotland: Transaction Date, Transaction Type, Transaction
|
|
7
|
+
Description, Debit Amount, Credit Amount, Balance
|
|
8
|
+
* Nationwide-style: Date, Description, Paid out, Paid in, Balance (with a few lines of
|
|
9
|
+
account details above the header)
|
|
10
|
+
* Monzo / Starling-style: Date, Name or Counter Party, Amount (signed), Balance
|
|
11
|
+
|
|
12
|
+
It needs a date, a description and either a signed amount or money in/out columns.
|
|
13
|
+
Dates may be dd/mm/yyyy, dd/mm/yy, yyyy-mm-dd or "01 Oct 2026".
|
|
14
|
+
|
|
15
|
+
Re-importing is always safe: every transaction gets a fingerprint and duplicates are
|
|
16
|
+
skipped, so overlapping exports can be uploaded without double counting.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import csv
|
|
22
|
+
import hashlib
|
|
23
|
+
import io
|
|
24
|
+
import re
|
|
25
|
+
import sqlite3
|
|
26
|
+
from collections import Counter
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import date, datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from .dates import strftime
|
|
32
|
+
from .money import to_pence
|
|
33
|
+
from .rules import categorise
|
|
34
|
+
|
|
35
|
+
# Column names banks use, most specific first. Names are compared after lower-casing
|
|
36
|
+
# and turning punctuation into spaces, so "Amount (GBP)" matches "amount gbp".
|
|
37
|
+
COLUMN_ALIASES: dict[str, list[str]] = {
|
|
38
|
+
"date": [
|
|
39
|
+
"transaction date",
|
|
40
|
+
"date",
|
|
41
|
+
"posting date",
|
|
42
|
+
"booking date",
|
|
43
|
+
"completed date",
|
|
44
|
+
"date of transaction",
|
|
45
|
+
"value date",
|
|
46
|
+
"started date",
|
|
47
|
+
],
|
|
48
|
+
"description": [
|
|
49
|
+
"transaction description",
|
|
50
|
+
"description",
|
|
51
|
+
"transaction details",
|
|
52
|
+
"details",
|
|
53
|
+
"narrative",
|
|
54
|
+
"counter party",
|
|
55
|
+
"counterparty",
|
|
56
|
+
"payee",
|
|
57
|
+
"name",
|
|
58
|
+
"merchant",
|
|
59
|
+
"memo",
|
|
60
|
+
"reference",
|
|
61
|
+
],
|
|
62
|
+
"tx_type": ["transaction type", "type"],
|
|
63
|
+
"amount": ["amount", "amount gbp", "transaction amount", "value", "amount local currency"],
|
|
64
|
+
"debit": ["debit amount", "debit", "paid out", "money out", "withdrawals", "amount out", "out"],
|
|
65
|
+
"credit": ["credit amount", "credit", "paid in", "money in", "deposits", "amount in", "in"],
|
|
66
|
+
"balance": ["balance", "running balance", "balance gbp", "available balance"],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
DATE_FORMATS = (
|
|
70
|
+
"%d/%m/%Y",
|
|
71
|
+
"%d/%m/%y",
|
|
72
|
+
"%Y-%m-%d",
|
|
73
|
+
"%d-%m-%Y",
|
|
74
|
+
"%d.%m.%Y",
|
|
75
|
+
"%Y/%m/%d",
|
|
76
|
+
"%d %b %Y",
|
|
77
|
+
"%d %B %Y",
|
|
78
|
+
"%d-%b-%Y",
|
|
79
|
+
"%d %b %y",
|
|
80
|
+
"%d-%b-%y",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ImportProblem(ValueError):
|
|
85
|
+
"""Raised when a file can't be read. The message is shown to the user."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class ParsedTransaction:
|
|
90
|
+
date: date
|
|
91
|
+
tx_type: str
|
|
92
|
+
description: str
|
|
93
|
+
amount: int # pence; credits positive, debits negative
|
|
94
|
+
balance: int | None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class ImportResult:
|
|
99
|
+
filename: str
|
|
100
|
+
rows_total: int = 0
|
|
101
|
+
rows_added: int = 0
|
|
102
|
+
rows_duplicate: int = 0
|
|
103
|
+
first_date: date | None = None
|
|
104
|
+
last_date: date | None = None
|
|
105
|
+
import_id: int | None = None
|
|
106
|
+
added_ids: list[int] = field(default_factory=list)
|
|
107
|
+
already_imported: bool = False
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def summary(self) -> str:
|
|
111
|
+
if self.rows_total == 0:
|
|
112
|
+
return f"{self.filename}: no transactions found."
|
|
113
|
+
span = ""
|
|
114
|
+
if self.first_date and self.last_date:
|
|
115
|
+
fmt = "%-d %b %Y"
|
|
116
|
+
span = f" ({strftime(self.first_date, fmt)} – {strftime(self.last_date, fmt)})"
|
|
117
|
+
dupes = f", {self.rows_duplicate} already imported" if self.rows_duplicate else ""
|
|
118
|
+
return f"{self.filename}: {self.rows_added} new transactions{dupes}{span}."
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_date(text: str) -> date:
|
|
122
|
+
text = text.strip()
|
|
123
|
+
iso = re.match(r"^(\d{4}-\d{2}-\d{2})[T ]", text) # 2026-10-01T09:30:00Z
|
|
124
|
+
if iso:
|
|
125
|
+
text = iso.group(1)
|
|
126
|
+
for pattern in DATE_FORMATS:
|
|
127
|
+
try:
|
|
128
|
+
return datetime.strptime(text, pattern).date()
|
|
129
|
+
except ValueError:
|
|
130
|
+
continue
|
|
131
|
+
raise ImportProblem(f"Couldn't read the date {text!r}. Expected something like 31/12/2026.")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_amount(text: str) -> int | None:
|
|
135
|
+
"""Like to_pence, but also understands (12.34), 12.34 DR/CR and a unicode minus."""
|
|
136
|
+
text = (text or "").strip().replace("\u2212", "-")
|
|
137
|
+
if not text:
|
|
138
|
+
return None
|
|
139
|
+
sign = 1
|
|
140
|
+
if text.startswith("(") and text.endswith(")"):
|
|
141
|
+
text, sign = text[1:-1], -1
|
|
142
|
+
suffix = re.search(r"\s*(CR|DR)$", text, re.IGNORECASE)
|
|
143
|
+
if suffix:
|
|
144
|
+
text = text[: suffix.start()]
|
|
145
|
+
sign = -1 if suffix.group(1).upper() == "DR" else 1
|
|
146
|
+
value = to_pence(text)
|
|
147
|
+
return None if value is None else sign * value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _clean_description(text: str) -> str:
|
|
151
|
+
return re.sub(r"\s+", " ", (text or "").strip())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _normalise(name: str) -> str:
|
|
155
|
+
return re.sub(r"[^a-z0-9]+", " ", name.lower()).strip()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def find_columns(header: list[str]) -> dict[str, int] | None:
|
|
159
|
+
"""Map our fields to column positions, or None if this isn't a header row."""
|
|
160
|
+
names = [_normalise(h) for h in header]
|
|
161
|
+
found: dict[str, int] = {}
|
|
162
|
+
for key, aliases in COLUMN_ALIASES.items():
|
|
163
|
+
for alias in aliases:
|
|
164
|
+
if alias in names and names.index(alias) not in found.values():
|
|
165
|
+
found[key] = names.index(alias)
|
|
166
|
+
break
|
|
167
|
+
has_money = "amount" in found or "debit" in found or "credit" in found
|
|
168
|
+
if "date" in found and "description" in found and has_money:
|
|
169
|
+
return found
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _decode(data: bytes | str) -> str:
|
|
174
|
+
if isinstance(data, str):
|
|
175
|
+
return data.lstrip("\ufeff")
|
|
176
|
+
for encoding in ("utf-8-sig", "cp1252"):
|
|
177
|
+
try:
|
|
178
|
+
return data.decode(encoding)
|
|
179
|
+
except UnicodeDecodeError:
|
|
180
|
+
continue
|
|
181
|
+
return data.decode("latin-1")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parse_csv(data: bytes | str) -> list[ParsedTransaction]:
|
|
185
|
+
"""Parse a bank CSV export into transactions (the file's order is preserved)."""
|
|
186
|
+
text = _decode(data)
|
|
187
|
+
if not text.strip():
|
|
188
|
+
raise ImportProblem("The file is empty.")
|
|
189
|
+
sample = text[:4096]
|
|
190
|
+
try:
|
|
191
|
+
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
|
|
192
|
+
except csv.Error:
|
|
193
|
+
dialect = csv.excel
|
|
194
|
+
rows = list(csv.reader(io.StringIO(text), dialect))
|
|
195
|
+
|
|
196
|
+
columns, header_at = None, 0
|
|
197
|
+
for i, row in enumerate(rows[:30]): # some banks put account details above the header
|
|
198
|
+
columns = find_columns(row)
|
|
199
|
+
if columns:
|
|
200
|
+
header_at = i
|
|
201
|
+
break
|
|
202
|
+
if columns is None:
|
|
203
|
+
raise ImportProblem(
|
|
204
|
+
"Couldn't find the transaction columns. The file needs a date, a description "
|
|
205
|
+
"and an amount (or money in and money out) column."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def cell(row: list[str], key: str) -> str:
|
|
209
|
+
i = columns.get(key)
|
|
210
|
+
return row[i] if i is not None and i < len(row) else ""
|
|
211
|
+
|
|
212
|
+
signed = "amount" in columns and not ("debit" in columns and "credit" in columns)
|
|
213
|
+
transactions = []
|
|
214
|
+
for line_no, row in enumerate(rows[header_at + 1 :], start=header_at + 2):
|
|
215
|
+
if not any(c.strip() for c in row) or not cell(row, "date").strip():
|
|
216
|
+
continue
|
|
217
|
+
try:
|
|
218
|
+
if signed:
|
|
219
|
+
amount = parse_amount(cell(row, "amount")) or 0
|
|
220
|
+
else:
|
|
221
|
+
debit = abs(parse_amount(cell(row, "debit")) or 0)
|
|
222
|
+
credit = abs(parse_amount(cell(row, "credit")) or 0)
|
|
223
|
+
amount = credit - debit
|
|
224
|
+
transactions.append(
|
|
225
|
+
ParsedTransaction(
|
|
226
|
+
date=parse_date(cell(row, "date")),
|
|
227
|
+
tx_type=cell(row, "tx_type").strip().upper(),
|
|
228
|
+
description=_clean_description(cell(row, "description")),
|
|
229
|
+
amount=amount,
|
|
230
|
+
balance=parse_amount(cell(row, "balance")),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
except (ValueError, ImportProblem) as exc:
|
|
234
|
+
raise ImportProblem(f"Line {line_no}: {exc}") from exc
|
|
235
|
+
return transactions
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def fingerprint(tx: ParsedTransaction, occurrence: int) -> str:
|
|
239
|
+
"""Stable identity for a transaction across overlapping exports.
|
|
240
|
+
|
|
241
|
+
The running balance makes otherwise identical rows (two £2 bus fares on the same
|
|
242
|
+
day) distinct; ``occurrence`` covers the rare case where even that repeats.
|
|
243
|
+
"""
|
|
244
|
+
key = "|".join(
|
|
245
|
+
[
|
|
246
|
+
tx.date.isoformat(),
|
|
247
|
+
str(tx.amount),
|
|
248
|
+
"" if tx.balance is None else str(tx.balance),
|
|
249
|
+
tx.description.upper(),
|
|
250
|
+
str(occurrence),
|
|
251
|
+
]
|
|
252
|
+
)
|
|
253
|
+
return hashlib.sha256(key.encode()).hexdigest()[:32]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def import_transactions(
|
|
257
|
+
conn: sqlite3.Connection,
|
|
258
|
+
transactions: list[ParsedTransaction],
|
|
259
|
+
*,
|
|
260
|
+
filename: str,
|
|
261
|
+
source: str,
|
|
262
|
+
sha256: str,
|
|
263
|
+
) -> ImportResult:
|
|
264
|
+
"""Insert new transactions, skip duplicates, and categorise what was added."""
|
|
265
|
+
result = ImportResult(filename=filename, rows_total=len(transactions))
|
|
266
|
+
if transactions:
|
|
267
|
+
result.first_date = min(t.date for t in transactions)
|
|
268
|
+
result.last_date = max(t.date for t in transactions)
|
|
269
|
+
|
|
270
|
+
seen: Counter[tuple] = Counter()
|
|
271
|
+
with conn:
|
|
272
|
+
cur = conn.execute(
|
|
273
|
+
"INSERT INTO imports(filename, sha256, source, rows_total, rows_added, "
|
|
274
|
+
"rows_duplicate, first_date, last_date) VALUES (?,?,?,?,0,0,?,?)",
|
|
275
|
+
(
|
|
276
|
+
filename,
|
|
277
|
+
sha256,
|
|
278
|
+
source,
|
|
279
|
+
len(transactions),
|
|
280
|
+
result.first_date and result.first_date.isoformat(),
|
|
281
|
+
result.last_date and result.last_date.isoformat(),
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
result.import_id = cur.lastrowid
|
|
285
|
+
# Insert oldest first so ids follow time order. Bank exports list newest first,
|
|
286
|
+
# so flip those before the (stable) sort to keep same-day rows in order too.
|
|
287
|
+
ordered = list(transactions)
|
|
288
|
+
if len(ordered) > 1 and ordered[0].date > ordered[-1].date:
|
|
289
|
+
ordered.reverse()
|
|
290
|
+
for tx in sorted(ordered, key=lambda t: t.date):
|
|
291
|
+
ident = (tx.date, tx.amount, tx.balance, tx.description.upper())
|
|
292
|
+
seen[ident] += 1
|
|
293
|
+
fp = fingerprint(tx, seen[ident])
|
|
294
|
+
cur = conn.execute(
|
|
295
|
+
"INSERT OR IGNORE INTO transactions"
|
|
296
|
+
"(fingerprint, date, tx_type, description, amount, balance, import_id) "
|
|
297
|
+
"VALUES (?,?,?,?,?,?,?)",
|
|
298
|
+
(
|
|
299
|
+
fp,
|
|
300
|
+
tx.date.isoformat(),
|
|
301
|
+
tx.tx_type,
|
|
302
|
+
tx.description,
|
|
303
|
+
tx.amount,
|
|
304
|
+
tx.balance,
|
|
305
|
+
result.import_id,
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
if cur.rowcount:
|
|
309
|
+
result.added_ids.append(cur.lastrowid)
|
|
310
|
+
result.rows_added = len(result.added_ids)
|
|
311
|
+
result.rows_duplicate = result.rows_total - result.rows_added
|
|
312
|
+
conn.execute(
|
|
313
|
+
"UPDATE imports SET rows_added = ?, rows_duplicate = ? WHERE id = ?",
|
|
314
|
+
(result.rows_added, result.rows_duplicate, result.import_id),
|
|
315
|
+
)
|
|
316
|
+
if result.added_ids:
|
|
317
|
+
categorise(conn, ids=result.added_ids)
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def import_file(conn: sqlite3.Connection, data: bytes, filename: str) -> ImportResult:
|
|
322
|
+
"""Import an uploaded bank CSV."""
|
|
323
|
+
if not filename.lower().endswith((".csv", ".txt")):
|
|
324
|
+
raise ImportProblem("Only CSV files can be imported. Download your statement as CSV.")
|
|
325
|
+
sha = hashlib.sha256(data).hexdigest()
|
|
326
|
+
transactions = parse_csv(data)
|
|
327
|
+
return import_transactions(conn, transactions, filename=filename, source="csv", sha256=sha)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def import_path(conn: sqlite3.Connection, path: Path | str) -> ImportResult:
|
|
331
|
+
path = Path(path)
|
|
332
|
+
return import_file(conn, path.read_bytes(), path.name)
|
keptwise/insights.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""What's unusual this month, worked out from your own history.
|
|
2
|
+
|
|
3
|
+
Normal ranges
|
|
4
|
+
-------------
|
|
5
|
+
For each category we look at the previous (up to) 12 complete months and take the
|
|
6
|
+
*median* (the typical month) and the *median absolute deviation* (how much it usually
|
|
7
|
+
moves). Both ignore one-off extremes, so a single big month doesn't distort "normal".
|
|
8
|
+
A month is flagged only when it falls clearly outside that range, so categories that
|
|
9
|
+
always swing around aren't flagged for ordinary ups and downs.
|
|
10
|
+
|
|
11
|
+
Bills
|
|
12
|
+
-----
|
|
13
|
+
Regular bills are also checked one by one: a bill that has gone out nearly every
|
|
14
|
+
month but hasn't this month is flagged as missing, and a fixed bill whose amount
|
|
15
|
+
changes (say £27.00 to £31.50) is flagged as changed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import calendar
|
|
21
|
+
import sqlite3
|
|
22
|
+
import statistics
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import date
|
|
25
|
+
|
|
26
|
+
from .money import fmt
|
|
27
|
+
from .reports import months as all_months
|
|
28
|
+
|
|
29
|
+
LOOKBACK = 12 # months of history used for "normal"
|
|
30
|
+
MIN_HISTORY = 4 # months needed before we judge anything
|
|
31
|
+
Z = 2.0 # how far outside normal before flagging (in robust standard deviations)
|
|
32
|
+
MIN_GAP = 15_00 # ignore differences smaller than £15
|
|
33
|
+
OCCASIONAL_MIN = 100_00 # occasional categories need at least £100 to be flagged
|
|
34
|
+
MAD_TO_SD = 1.4826 # scales MAD to a standard deviation for normally distributed data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Normal:
|
|
39
|
+
"""A category's typical month. Amounts are *spend* (positive) or, for income
|
|
40
|
+
categories, money received (positive)."""
|
|
41
|
+
|
|
42
|
+
median: int
|
|
43
|
+
low: int
|
|
44
|
+
high: int
|
|
45
|
+
months: int
|
|
46
|
+
income: bool
|
|
47
|
+
occasional: bool # used in fewer than half of months
|
|
48
|
+
highest: int # biggest month in the history
|
|
49
|
+
|
|
50
|
+
def describe(self) -> str:
|
|
51
|
+
if self.occasional:
|
|
52
|
+
return f"your biggest month before was {pounds(self.highest)}"
|
|
53
|
+
if self.low == 0:
|
|
54
|
+
return f"usually up to {pounds(self.high)}"
|
|
55
|
+
return f"usually {pounds(self.low)} to {pounds(self.high)}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def pounds(pence: int) -> str:
|
|
59
|
+
"""Whole pounds for ranges: £1,240."""
|
|
60
|
+
return f"£{round(pence / 100):,}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Flag:
|
|
65
|
+
kind: str # above | below | missing | changed
|
|
66
|
+
category_id: int | None
|
|
67
|
+
name: str
|
|
68
|
+
bucket: str
|
|
69
|
+
amount: int
|
|
70
|
+
message: str
|
|
71
|
+
good: bool # True when it's good news (spent less, earned more)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def complete_months(conn: sqlite3.Connection) -> list[str]:
|
|
75
|
+
"""Months whose data covers the whole month (drops a partial first/last month)."""
|
|
76
|
+
ms = all_months(conn)
|
|
77
|
+
if not ms:
|
|
78
|
+
return []
|
|
79
|
+
bounds = {
|
|
80
|
+
row["m"]: (date.fromisoformat(row["first"]), date.fromisoformat(row["last"]))
|
|
81
|
+
for row in conn.execute(
|
|
82
|
+
"SELECT substr(date,1,7) AS m, MIN(date) AS first, MAX(date) AS last "
|
|
83
|
+
"FROM transactions GROUP BY m"
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
result = []
|
|
87
|
+
for i, m in enumerate(ms):
|
|
88
|
+
first, last = bounds[m]
|
|
89
|
+
days = calendar.monthrange(first.year, first.month)[1]
|
|
90
|
+
if i == 0 and first.day > 4:
|
|
91
|
+
continue
|
|
92
|
+
if i == len(ms) - 1 and last.day < days - 3:
|
|
93
|
+
continue
|
|
94
|
+
result.append(m)
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def monthly_net(conn: sqlite3.Connection) -> dict[tuple[int | None, str], int]:
|
|
99
|
+
"""(category_id, month) -> net spend (money out minus money in) in pence."""
|
|
100
|
+
rows = conn.execute(
|
|
101
|
+
"SELECT category_id, substr(date,1,7) AS m, -SUM(amount) AS spend "
|
|
102
|
+
"FROM transactions GROUP BY category_id, m"
|
|
103
|
+
)
|
|
104
|
+
return {(r["category_id"], r["m"]): r["spend"] for r in rows}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def normal_range(values: list[int]) -> Normal | None:
|
|
108
|
+
"""Typical range from monthly *signed spend* values (income is negative)."""
|
|
109
|
+
if len(values) < MIN_HISTORY:
|
|
110
|
+
return None
|
|
111
|
+
income = statistics.median(values) < 0
|
|
112
|
+
vals = [-v for v in values] if income else list(values)
|
|
113
|
+
med = statistics.median(vals)
|
|
114
|
+
mad = statistics.median(abs(v - med) for v in vals)
|
|
115
|
+
spread = max(MAD_TO_SD * mad, 0.15 * abs(med), 10_00)
|
|
116
|
+
low = max(0, round(med - Z * spread))
|
|
117
|
+
high = round(med + Z * spread)
|
|
118
|
+
occasional = sum(1 for v in vals if v) < len(vals) / 2
|
|
119
|
+
return Normal(round(med), low, high, len(vals), income, occasional, max(vals))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def normals(conn: sqlite3.Connection, month: str) -> dict[int | None, Normal]:
|
|
123
|
+
"""Normal range for every non-investment category, using months before ``month``."""
|
|
124
|
+
history = [m for m in complete_months(conn) if m < month][-LOOKBACK:]
|
|
125
|
+
if len(history) < MIN_HISTORY:
|
|
126
|
+
return {}
|
|
127
|
+
net = monthly_net(conn)
|
|
128
|
+
cats = conn.execute("SELECT id, bucket FROM categories WHERE bucket != 'investment'").fetchall()
|
|
129
|
+
ids: list[int | None] = [c["id"] for c in cats] + [None]
|
|
130
|
+
result = {}
|
|
131
|
+
for cid in ids:
|
|
132
|
+
values = [net.get((cid, m), 0) for m in history]
|
|
133
|
+
if not any(values):
|
|
134
|
+
continue
|
|
135
|
+
normal = normal_range(values)
|
|
136
|
+
if normal:
|
|
137
|
+
result[cid] = normal
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def compare(value: int, normal: Normal) -> str:
|
|
142
|
+
"""'above', 'below' or 'normal' for a month's signed spend against its range."""
|
|
143
|
+
amount = -value if normal.income else value
|
|
144
|
+
if normal.occasional:
|
|
145
|
+
# Things you only spend on now and then: flag only a clearly record month.
|
|
146
|
+
big = amount >= OCCASIONAL_MIN and amount > 1.5 * normal.highest
|
|
147
|
+
return "above" if big else "normal"
|
|
148
|
+
if amount < 0:
|
|
149
|
+
return "normal" # more came in than went out (refunds, people paying back)
|
|
150
|
+
if amount > normal.high and amount - normal.median >= MIN_GAP:
|
|
151
|
+
return "above"
|
|
152
|
+
if amount < normal.low and normal.median - amount >= MIN_GAP:
|
|
153
|
+
return "below"
|
|
154
|
+
return "normal"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def month_flags(conn: sqlite3.Connection, month: str) -> list[Flag]:
|
|
158
|
+
"""Everything worth a look in ``month``: unusual categories and bill problems."""
|
|
159
|
+
complete = month in complete_months(conn)
|
|
160
|
+
last_day_seen = conn.execute(
|
|
161
|
+
"SELECT MAX(date) FROM transactions WHERE substr(date,1,7) = ?", (month,)
|
|
162
|
+
).fetchone()[0]
|
|
163
|
+
if last_day_seen is None:
|
|
164
|
+
return []
|
|
165
|
+
seen_until = date.fromisoformat(last_day_seen)
|
|
166
|
+
flags: list[Flag] = []
|
|
167
|
+
flags += _bill_flags(conn, month, complete, seen_until)
|
|
168
|
+
|
|
169
|
+
ranges = normals(conn, month)
|
|
170
|
+
net = monthly_net(conn)
|
|
171
|
+
names = {
|
|
172
|
+
r["id"]: (r["name"], r["bucket"])
|
|
173
|
+
for r in conn.execute("SELECT id, name, bucket FROM categories")
|
|
174
|
+
}
|
|
175
|
+
flagged = {f.category_id for f in flags}
|
|
176
|
+
for cid, normal in ranges.items():
|
|
177
|
+
if cid is None or cid in flagged:
|
|
178
|
+
continue # uncategorised spending has its own prompt
|
|
179
|
+
name, bucket = names.get(cid, ("No category", "adhoc"))
|
|
180
|
+
value = net.get((cid, month), 0)
|
|
181
|
+
verdict = compare(value, normal)
|
|
182
|
+
# Part months naturally look low, so only "above" counts until the month is in.
|
|
183
|
+
if verdict == "normal" or (verdict == "below" and not complete):
|
|
184
|
+
continue
|
|
185
|
+
amount = -value if normal.income else value
|
|
186
|
+
if normal.income:
|
|
187
|
+
word = "more" if verdict == "above" else "less"
|
|
188
|
+
message = f"{name}: {fmt(amount)} came in, {word} than normal ({normal.describe()})."
|
|
189
|
+
good = verdict == "above"
|
|
190
|
+
else:
|
|
191
|
+
word = "more" if verdict == "above" else "less"
|
|
192
|
+
message = f"{name}: {fmt(amount)}, {word} than normal ({normal.describe()})."
|
|
193
|
+
good = verdict == "below"
|
|
194
|
+
flags.append(Flag(verdict, cid, name, bucket, amount, message, good))
|
|
195
|
+
|
|
196
|
+
order = {"missing": 0, "changed": 1, "above": 2, "below": 3}
|
|
197
|
+
flags.sort(key=lambda f: (order[f.kind], -abs(f.amount)))
|
|
198
|
+
return flags
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _bill_flags(
|
|
202
|
+
conn: sqlite3.Connection, month: str, complete: bool, seen_until: date
|
|
203
|
+
) -> list[Flag]:
|
|
204
|
+
history = [m for m in complete_months(conn) if m < month][-6:]
|
|
205
|
+
if len(history) < 3:
|
|
206
|
+
return []
|
|
207
|
+
rows = conn.execute(
|
|
208
|
+
"SELECT t.category_id, c.name, substr(t.date,1,7) AS m, t.date, t.amount "
|
|
209
|
+
"FROM transactions t JOIN categories c ON c.id = t.category_id "
|
|
210
|
+
"WHERE c.bucket = 'regular' AND t.amount < 0 ORDER BY t.date"
|
|
211
|
+
).fetchall()
|
|
212
|
+
payments: dict[int, list[sqlite3.Row]] = {}
|
|
213
|
+
for r in rows:
|
|
214
|
+
payments.setdefault(r["category_id"], []).append(r)
|
|
215
|
+
|
|
216
|
+
flags = []
|
|
217
|
+
for cid, items in payments.items():
|
|
218
|
+
name = items[0]["name"]
|
|
219
|
+
by_month: dict[str, list[sqlite3.Row]] = {}
|
|
220
|
+
for r in items:
|
|
221
|
+
by_month.setdefault(r["m"], []).append(r)
|
|
222
|
+
months_paid = [m for m in history if m in by_month]
|
|
223
|
+
if len(months_paid) < len(history) - 1:
|
|
224
|
+
continue # not a reliable monthly bill
|
|
225
|
+
this_month = by_month.get(month, [])
|
|
226
|
+
|
|
227
|
+
if not this_month:
|
|
228
|
+
usual_day = statistics.median(
|
|
229
|
+
date.fromisoformat(by_month[m][0]["date"]).day for m in months_paid
|
|
230
|
+
)
|
|
231
|
+
if complete or seen_until.day > usual_day + 5:
|
|
232
|
+
usual = statistics.median(
|
|
233
|
+
-sum(r["amount"] for r in by_month[m]) for m in months_paid
|
|
234
|
+
)
|
|
235
|
+
flags.append(
|
|
236
|
+
Flag(
|
|
237
|
+
"missing",
|
|
238
|
+
cid,
|
|
239
|
+
name,
|
|
240
|
+
"regular",
|
|
241
|
+
round(usual),
|
|
242
|
+
f"{name} hasn't gone out this month. It's usually "
|
|
243
|
+
f"{fmt(round(usual))} around the {_ordinal(round(usual_day))}.",
|
|
244
|
+
False,
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# A fixed bill (same amount the last three times) that has changed.
|
|
250
|
+
if len(this_month) != 1 or any(len(by_month[m]) != 1 for m in months_paid[-3:]):
|
|
251
|
+
continue
|
|
252
|
+
previous = [-by_month[m][0]["amount"] for m in months_paid[-3:]]
|
|
253
|
+
current = -this_month[0]["amount"]
|
|
254
|
+
if len(set(previous)) == 1 and current != previous[-1]:
|
|
255
|
+
went = "up" if current > previous[-1] else "down"
|
|
256
|
+
flags.append(
|
|
257
|
+
Flag(
|
|
258
|
+
"changed",
|
|
259
|
+
cid,
|
|
260
|
+
name,
|
|
261
|
+
"regular",
|
|
262
|
+
current,
|
|
263
|
+
f"{name} went {went} from {fmt(previous[-1])} to {fmt(current)}.",
|
|
264
|
+
went == "down",
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
return flags
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _ordinal(n: int) -> str:
|
|
271
|
+
suffix = "th" if 11 <= n % 100 <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
|
|
272
|
+
return f"{n}{suffix}"
|