fix: Bestandsabgleich per Token-Match und Volatilitaets-Hinweis fuer Vorschlaege

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 21:46:10 +02:00
parent a520390e31
commit aff8e9b28d
2 changed files with 130 additions and 9 deletions

View File

@@ -16,6 +16,7 @@ ganzzahlige Tage-Arithmetik, niemals float/Decimal-Bruchteile von Tagen.
"""
from __future__ import annotations
import re
import statistics
from collections import Counter
from dataclasses import dataclass
@@ -69,6 +70,24 @@ MERGE_GAP_MAX_NUM, MERGE_GAP_MAX_DEN = 16, 10 # 1.6
MERGE_DUE_DAY_TOL = 3 # Schritt 6: Faelligkeitstag-Toleranz in Tagen
BESTAND_DUE_DAY_TOL = 2 # Schritt 8: Faelligkeitstag-Toleranz in Tagen
# Bestandsabgleich, Token-Match (Live-Gate A9-Fund, Nachtrag 4): kuratierte
# Fixposten tragen haeufig einen Alias-/Variabel-Namen, der weder Substring
# noch betragsaehnlich zum automatisch erkannten Vorschlag ist (Beispiel aus
# der echten Datenbasis: Fixposten "Mastercard-Abrechnung Volksbank
# (variabel, letzter Wert)" vs. erkannter Vorschlag "Volksbank Ulm-Biberach
# eG" - Betrag weicht um >10% ab, kein Substring-Treffer). Ein gemeinsames,
# hinreichend spezifisches Namens-Token (>=5 Zeichen, um generische Woerter
# wie "Bank" nicht faelschlich matchen zu lassen) bei gleichem Rhythmus und
# nahem Faelligkeitstag gilt als ausreichendes Indiz fuer denselben Fixposten.
TOKEN_MIN_LEN = 5
# Volatilitaets-Hinweis (Live-Gate A9-Fund, Nachtrag 4): wenn der
# Betrags-Cluster-Split (Schritt 3) die neueste Buchung der Empfaenger-Gruppe
# abgetrennt hat (weil sie zu stark vom Serien-Betrag abweicht), ist der
# vorgeschlagene Betrag ggf. schon wieder veraltet - keine Unterdrueckung,
# nur ein Warnhinweis fuer die Nutzerin/den Nutzer.
VOLATILITAETS_HINWEIS = "Beträge schwanken stark letzte Buchung weicht ab"
def _norm(name: str) -> str:
"""Normalisiert einen Empfaenger-Namen fuer Gruppen- und
@@ -78,6 +97,14 @@ def _norm(name: str) -> str:
return " ".join(name.split()).casefold()
def _tokens(name: str) -> set[str]:
"""Zerlegt einen normalisierten Namen an Nicht-Alphanumerik in Tokens
(fuer den Token-Match im Bestandsabgleich, Schritt 8). Nur Tokens ab
TOKEN_MIN_LEN Zeichen zaehlen, damit kurze generische Woerter ("eG",
"AG", "Bank") keine falschen Treffer erzeugen."""
return {tok for tok in re.split(r"[^a-z0-9]+", _norm(name)) if len(tok) >= TOKEN_MIN_LEN}
def _rel_diff(a: Decimal, b: Decimal) -> Decimal:
"""Relative Differenz von Betrag a zur Referenz b (immer >= 0), als
Decimal. b=0 kommt praktisch nicht vor (eine Nullbuchung bildet keine
@@ -90,9 +117,15 @@ def _rel_diff(a: Decimal, b: Decimal) -> Decimal:
@dataclass
class _Series:
"""Eine erkannte Serie: chronologisch sortierte Buchungen eines
Betrags-Clusters mit zugeordnetem Rhythmus."""
Betrags-Clusters mit zugeordnetem Rhythmus.
`volatile` markiert, dass der Cluster-Split (Schritt 3) innerhalb der
Empfaenger-Gruppe eine NEUERE, betragsmaessig abweichende Buchung
abgetrennt hat - der hier vorgeschlagene Betrag koennte also schon
wieder veraltet sein (siehe VOLATILITAETS_HINWEIS)."""
items: list[Transaction]
rhythm: str
volatile: bool = False
@property
def first(self) -> Transaction:
@@ -183,6 +216,7 @@ def _try_merge(series_list: list[_Series]) -> list[_Series]:
merged = _Series(
items=sorted(a.items + b.items, key=lambda t: t.booking_date),
rhythm=a.rhythm,
volatile=a.volatile or b.volatile,
)
series_list = [s for k, s in enumerate(series_list) if k not in (i, j)]
series_list.append(merged)
@@ -196,12 +230,21 @@ def _try_merge(series_list: list[_Series]) -> list[_Series]:
def _covered_by_existing(cand_name: str, cand_amount: Decimal, rhythm: str, due_day: int,
existing: list[RecurringItem]) -> bool:
"""Schritt 8 (Bestandsabgleich): ein Vorschlag entfaellt, wenn er bereits
als Fixposten gepflegt ist - entweder ueber einen Namens-Substring-Match
(normalisiert, in beide Richtungen: sowohl Kurz- als auch
Langschreibweisen kommen in der Praxis in beiden Datenquellen vor) oder
ueber Rhythmus + Faelligkeitstag + Betrag innerhalb enger Toleranz (falls
der Fixposten unter einem ganz anderen Namen gepflegt wurde)."""
als Fixposten gepflegt ist - ueber einen von drei Wegen:
(a) Namens-Substring-Match (normalisiert, in beide Richtungen: sowohl
Kurz- als auch Langschreibweisen kommen in der Praxis in beiden
Datenquellen vor);
(b) Rhythmus + Faelligkeitstag + Betrag innerhalb enger Toleranz (falls
der Fixposten unter einem ganz anderen Namen gepflegt wurde);
(c) Token-Match: gleicher Rhythmus, Faelligkeitstag-Differenz <= 2 UND
mindestens ein gemeinsames Namens-Token (>=5 Zeichen) - faengt
kuratierte Alias-/Variabel-Fixposten, deren Name UND Betrag stark
vom automatisch erkannten Vorschlag abweichen (Live-Gate A9-Fund:
Fixposten "Mastercard-Abrechnung Volksbank (variabel, letzter
Wert)" deckt den Vorschlag "Volksbank Ulm-Biberach eG" ab, obwohl
weder (a) noch (b) greifen)."""
cand_norm = _norm(cand_name)
cand_tokens = _tokens(cand_name)
for item in existing:
item_norm = _norm(item.name)
if cand_norm in item_norm or item_norm in cand_norm:
@@ -210,6 +253,10 @@ def _covered_by_existing(cand_name: str, cand_amount: Decimal, rhythm: str, due_
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
and _rel_diff(cand_amount, Decimal(item.amount)) <= BESTAND_TOL):
return True
if (item.rhythm == rhythm
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
and cand_tokens & _tokens(item.name)):
return True
return False
@@ -242,12 +289,36 @@ def suggest_recurring(session: Session, today: date | None = None) -> list[dict]
series_by_account: dict[int, list[_Series]] = {}
for (account_id, _name_norm), items in groups.items():
items_sorted = sorted(items, key=lambda t: t.booking_date)
for cluster in _amount_clusters(items_sorted):
rhythm = _classify([t.booking_date for t in cluster])
clusters = _amount_clusters(items_sorted)
classified = [(cluster, _classify([t.booking_date for t in cluster]))
for cluster in clusters]
# Fuer den Volatilitaets-Check zaehlt eine neuere Buchung nur dann als
# "abgetrennt", wenn sie NICHT bereits zu einem ANDEREN qualifizierten
# (klassifizierten) Cluster derselben Gruppe gehoert - sonst waeren
# zwei parallele, stabile Vertraege (jeder fuer sich eine gueltige
# eigene Serie) faelschlich als "volatil" markiert, nur weil der
# jeweils andere Vertrag zufaellig spaeter im Monat faellig ist
# (Nachtrag 3b, Fable-Gate-Korrektur nach dem ersten Live-Gate-Fund).
qualified_items = {t for cluster, rhythm in classified if rhythm is not None
for t in cluster}
for cluster, rhythm in classified:
if rhythm is None:
continue
# Volatilitaets-Hinweis: hat der Cluster-Split innerhalb DIESER
# Empfaenger-Gruppe (gleiches Konto, gleiches Vorzeichen) eine
# NEUERE Buchung in einen UNQUALIFIZIERTEN Cluster abgetrennt
# (z.B. eine einzelne Ausreisser-Buchung, die allein keine Serie
# bildet), ist der hier vorgeschlagene (letzte) Betrag ggf. schon
# veraltet.
cluster_sign = cluster[-1].amount > 0
volatile = any(
(t.amount > 0) == cluster_sign
and t.booking_date > cluster[-1].booking_date
and t not in qualified_items
for t in items_sorted
)
series_by_account.setdefault(account_id, []).append(
_Series(items=cluster, rhythm=rhythm))
_Series(items=cluster, rhythm=rhythm, volatile=volatile))
existing = list(session.execute(select(RecurringItem)).scalars())
@@ -279,6 +350,9 @@ def suggest_recurring(session: Session, today: date | None = None) -> list[dict]
if abs(amount) > abs(previous):
hinweis = f"Betrag zuletzt gestiegen (vorher {eur(abs(previous))} €)"
if s.volatile:
hinweis = f"{hinweis} {VOLATILITAETS_HINWEIS}".strip()
# Schritt 8: Bestandsabgleich.
if _covered_by_existing(name, amount, s.rhythm, due_day, existing):
continue

View File

@@ -253,6 +253,13 @@ def test_suggest_zwei_vertraege_getrennt(db):
out = suggest_recurring(db, today=TODAY)
assert len(out) == 2
assert {s["amount"] for s in out} == {Decimal("-346.23"), Decimal("-145.21")}
# Nachtrag 3b: beide Vertraege sind fuer sich genommen stabile,
# qualifizierte Serien - die jeweils neuere Buchung des ANDEREN Vertrags
# gehoert selbst zu einer qualifizierten Serie und darf deshalb NICHT als
# "abgetrennte neueste Buchung" gewertet werden (sonst waere einer der
# beiden faelschlich als "volatil" markiert, nur weil der andere Vertrag
# einen Tag spaeter faellig ist).
assert all(s["hinweis"] == "" for s in out)
def test_try_merge_kombiniert_serien_ueber_gruppengrenzen():
@@ -320,3 +327,43 @@ def test_suggest_umfirmierung_merge_verschiebt_kategorie_mehrheit(db):
assert out[0]["name"] == "Neue Firma GmbH" and out[0]["amount"] == Decimal("-52.00")
# Kategorie-Mehrheit kippt durch den Merge von B (3) auf A (4):
assert out[0]["category_id"] == cat_a.id
# ------------------------------------------------- Nachtrag 4 (A9-Live-Gate-Fund)
# Echter Fall aus dem Live-Gate: Fixposten 44 "Mastercard-Abrechnung Volksbank
# (variabel, letzter Wert)" (monthly, due_day 7, -296.07) deckte den Vorschlag
# "Volksbank Ulm-Biberach eG" (monthly, due_day 5, -584.43) nicht ab, weil
# weder Substring- noch Betrags-Toleranz-Regel griffen. Betraege/Namen hier
# synthetisch nachgebildet (keine echten Kontodaten, siehe CLAUDE.md).
def test_suggest_alias_recurring_item_token_match(db):
acc = _acc(db)
db.add(RecurringItem(name="Mastercard-Abrechnung Volksbank (variabel, letzter Wert)",
amount=Decimal("-296.07"), rhythm="monthly", due_day=7))
for d, a in [(date(2026, 4, 5), "-560.00"), (date(2026, 5, 5), "-580.00"),
(date(2026, 6, 5), "-590.00"), (date(2026, 7, 5), "-580.00")]:
_tx(db, acc, d, a, "Volksbank Ulm-Biberach eG")
db.commit()
# Substring-Match (a) schlaegt fehl (kein Teilstring gemeinsam), Betrags-
# Toleranz (b) auch (-580 vs. -296.07, >10%) - erst der Token-Match (c)
# ueber das gemeinsame Token "volksbank" (Rhythmus gleich, due_day 5 vs. 7
# -> Differenz 2 <= 2) deckt den Vorschlag ab.
assert suggest_recurring(db, today=TODAY) == []
def test_suggest_volatilitaetshinweis_bei_abgetrennter_neuester_buchung(db):
acc = _acc(db)
for d in [date(2026, 1, 5), date(2026, 2, 5), date(2026, 3, 5), date(2026, 4, 5)]:
_tx(db, acc, d, "-200.00", "Schwankender Anbieter GmbH")
# Neueste Buchung weicht >35% vom Serien-Betrag ab -> eigener Cluster,
# klassifiziert selbst nicht (nur 1 Buchung) -> die vorgeschlagene Serie
# bleibt die -200.00-Serie, aber mit Volatilitaets-Warnhinweis.
_tx(db, acc, date(2026, 5, 5), "-600.00", "Schwankender Anbieter GmbH")
db.commit()
out = suggest_recurring(db, today=date(2026, 5, 20))
assert len(out) == 1
assert out[0]["amount"] == Decimal("-200.00") # Betrag NICHT durch die 600er-Buchung verfaelscht
assert "schwanken" in out[0]["hinweis"].lower()