diff --git a/finance/app/services/suggestions.py b/finance/app/services/suggestions.py index 3317bd0..fc1703e 100644 --- a/finance/app/services/suggestions.py +++ b/finance/app/services/suggestions.py @@ -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 diff --git a/finance/tests/test_planning_api.py b/finance/tests/test_planning_api.py index 1170321..5cc1f38 100644 --- a/finance/tests/test_planning_api.py +++ b/finance/tests/test_planning_api.py @@ -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()