From 85b0836d6e0eeaa5cbf7fa72a1bac05eb98f64b1 Mon Sep 17 00:00:00 2001 From: Marcel Enguehard Date: Tue, 18 Aug 2026 18:07:23 +0200 Subject: [PATCH] Add reference amortisation generator for known-answer tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Computes the mensualité, échéancier, coût total and TAEG of a French fixed-instalment loan in Decimal arithmetic, and emits the four anonymised sample offers as either JSON or a Rust fixtures module. Conventions (taux mensuel proportionnel, arrondi ROUND_HALF_UP au centime, dernière échéance ajustée pour solder le CRD) are verified against a real amortisation table via the `verify` subcommand, and cross-checked against the ANIL simulator on 2026-08-03. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + scripts/loan_schedule.py | 746 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 749 insertions(+) create mode 100644 scripts/loan_schedule.py diff --git a/.gitignore b/.gitignore index a934e75..bfd4830 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ # Local environment .env .env.local + +# Python tooling +__pycache__/ diff --git a/scripts/loan_schedule.py b/scripts/loan_schedule.py new file mode 100644 index 0000000..3c48a2c --- /dev/null +++ b/scripts/loan_schedule.py @@ -0,0 +1,746 @@ +#!/usr/bin/env python3 +"""Amortisation d'un prêt immobilier français : mensualité, échéancier, coût, TAEG. + +Sert à produire les valeurs de référence des known-answer tests de `immo-core`. +Toute l'arithmétique est en `Decimal` (jamais de `float`) et arrondie au centime +avec ROUND_HALF_UP, comme les tableaux d'amortissement bancaires. + +Conventions retenues, vérifiées contre un tableau d'amortissement réel +(Caisse d'Épargne, 2020, cf. `verify`) : + +- Taux mensuel **proportionnel** : `taux_nominal_annuel / 12`. C'est ce + qu'imprime l'offre ("TAUX : 1,1500 % PROPORTIONNEL"), et non un taux + équivalent actuariel `(1+t)^(1/12)-1`. +- Mensualité constante : `C * i / (1 - (1+i)^-n)`, arrondie au centime. +- Ligne d'échéance : `intérêts = arrondi(CRD * i)`, `capital = mensualité - + intérêts`. La dernière échéance est ajustée pour solder exactement le CRD. +- Durée totale = durée du différé + durée d'amortissement (l'offre de référence + annonce 300 mois pour 3 mois de différé + 297 échéances). + +Recoupement avec le simulateur de l'ANIL (vérifié le 2026-08-03, +https://www.anil.org/outils/outils-de-calcul/echeancier-dun-pret/) : mensualité, +TAEG et première ligne d'échéance sont identiques au centime. Les totaux +diffèrent de 0,14 à 0,36 EUR parce que l'ANIL somme la mensualité **non +arrondie** (240 x 863,0184980... = 207 124,44) là où l'emprunteur paie +réellement 240 x 863,02. C'est le tableau bancaire qui tranche : il arrondit +chaque ligne, et c'est cette convention-là qui reproduit ses 297 échéances. +L'ANIL refuse un taux nul et un capital décimal : le PTZ et la base capitalisée +exacte n'y sont pas testables. + +TAEG : taux actuariel annuel qui annule la valeur actualisée des flux, périodes +exprimées en années (art. R. 314-3 du code de la consommation). Avec des +échéances mensuelles régulières il résout +`capital_net = somme(échéance_k / (1 + TAEG)^(k/12))`. +Le capital net est diminué des frais payés au déblocage (frais de dossier, +garantie), qui font donc monter le TAEG au-dessus du taux nominal. +L'assurance emprunteur n'est pas modélisée ici : à ajouter aux flux quand on +voudra un TAEG assurance comprise. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass, field +from decimal import Decimal, getcontext, ROUND_HALF_UP + +# Marge confortable pour les puissances et la recherche de racine du TAEG. +getcontext().prec = 40 + +CENT = Decimal("0.01") +MONTHS_PER_YEAR = Decimal(12) + + +def to_cents(value: Decimal) -> Decimal: + """Arrondit au centime, ROUND_HALF_UP (convention bancaire française).""" + return value.quantize(CENT, rounding=ROUND_HALF_UP) + + +class DeferralKind: + """Traitement des intérêts pendant le différé. + + NONE : pas de différé. + TOTAL : rien n'est payé, les intérêts sont capitalisés dans le CRD. + PARTIAL : seuls les intérêts sont payés (intérêts intercalaires), le + capital reste inchangé. + """ + + NONE = "none" + TOTAL = "total" + PARTIAL = "partial" + + ALL = (NONE, TOTAL, PARTIAL) + + +@dataclass(frozen=True) +class Installment: + rank: int + phase: str # "differe" ou "amortissement" + payment: Decimal + principal: Decimal + interest: Decimal + capitalised_interest: Decimal + remaining_capital: Decimal + + def as_dict(self) -> dict: + return { + "rank": self.rank, + "phase": self.phase, + "payment": str(self.payment), + "principal": str(self.principal), + "interest": str(self.interest), + "capitalised_interest": str(self.capitalised_interest), + "remaining_capital": str(self.remaining_capital), + } + + +@dataclass +class LoanOffer: + """Une offre de prêt à mensualités constantes.""" + + id: str # identifiant stable, sert de nom de fonction Rust + label: str + borrowed_capital: Decimal + annual_nominal_rate: Decimal # 0.0341 pour 3,41 % + total_duration_in_months: int + deferral_in_months: int = 0 + deferral_kind: str = DeferralKind.NONE + upfront_fees: Decimal = field(default_factory=lambda: Decimal(0)) + + def __post_init__(self) -> None: + if self.borrowed_capital <= 0: + raise ValueError(f"{self.label}: capital emprunté non strictement positif") + if self.annual_nominal_rate < 0: + raise ValueError(f"{self.label}: taux nominal négatif") + if self.total_duration_in_months <= 0: + raise ValueError(f"{self.label}: durée totale nulle") + if self.deferral_kind not in DeferralKind.ALL: + raise ValueError(f"{self.label}: différé inconnu {self.deferral_kind!r}") + if self.deferral_in_months < 0: + raise ValueError(f"{self.label}: différé négatif") + if self.deferral_in_months >= self.total_duration_in_months: + raise ValueError(f"{self.label}: le différé absorbe toute la durée") + if self.deferral_in_months == 0 and self.deferral_kind != DeferralKind.NONE: + raise ValueError(f"{self.label}: différé de 0 mois mais de type non nul") + if self.deferral_in_months > 0 and self.deferral_kind == DeferralKind.NONE: + raise ValueError(f"{self.label}: différé non nul sans type de différé") + + @property + def monthly_rate(self) -> Decimal: + """Taux mensuel proportionnel. Non arrondi : c'est un taux, pas un montant.""" + return self.annual_nominal_rate / MONTHS_PER_YEAR + + @property + def amortisation_in_months(self) -> int: + return self.total_duration_in_months - self.deferral_in_months + + @property + def amortised_capital(self) -> Decimal: + """Capital sur lequel court l'amortissement, après capitalisation du différé.""" + if self.deferral_kind != DeferralKind.TOTAL: + return self.borrowed_capital + capital = self.borrowed_capital + for _ in range(self.deferral_in_months): + capital = to_cents(capital + to_cents(capital * self.monthly_rate)) + return capital + + @property + def monthly_repayment(self) -> Decimal: + """Mensualité constante de la phase d'amortissement, arrondie au centime.""" + capital = self.amortised_capital + n = self.amortisation_in_months + i = self.monthly_rate + if i == 0: + # Prêt à taux zéro : la mensualité est un simple prorata du capital. + return to_cents(capital / Decimal(n)) + discount = (Decimal(1) + i) ** -n + return to_cents(capital * i / (Decimal(1) - discount)) + + def schedule(self) -> list[Installment]: + """Échéancier complet, différé inclus, dernière échéance ajustée.""" + rows: list[Installment] = [] + capital = self.borrowed_capital + rank = 0 + + for _ in range(self.deferral_in_months): + rank += 1 + interest = to_cents(capital * self.monthly_rate) + if self.deferral_kind == DeferralKind.TOTAL: + capital = to_cents(capital + interest) + rows.append( + Installment(rank, "differe", Decimal("0.00"), Decimal("0.00"), + Decimal("0.00"), interest, capital) + ) + else: # PARTIAL : les intérêts sont payés, le capital ne bouge pas. + rows.append( + Installment(rank, "differe", interest, Decimal("0.00"), + interest, Decimal("0.00"), capital) + ) + + payment = self.monthly_repayment + n = self.amortisation_in_months + for k in range(1, n + 1): + rank += 1 + interest = to_cents(capital * self.monthly_rate) + if k == n: + # Dernière échéance : elle solde le capital restant. La banque + # garde la mensualité constante et laisse la colonne intérêts + # absorber la dérive d'arrondi accumulée (offre de référence : + # 1 908,59 de capital + 0,41 d'intérêts = 1 909,00, au lieu de + # 1,83 d'intérêts stricts). Si la dérive va dans l'autre sens, + # la mensualité ne suffit pas et l'échéance est relevée. + principal = capital + # L'échéance reste bornée par le capital restant (plancher) et + # par capital + intérêts stricts (plafond) : sur un PTZ, où les + # intérêts stricts sont nuls, elle se réduit donc au capital et + # la dérive d'arrondi ne peut pas se déguiser en intérêts. + due = min(max(payment, capital), to_cents(capital + interest)) + interest = to_cents(due - principal) + else: + due = payment + principal = to_cents(due - interest) + capital = to_cents(capital - principal) + rows.append( + Installment(rank, "amortissement", due, principal, interest, + Decimal("0.00"), capital) + ) + + if capital != 0: + raise AssertionError(f"{self.label}: capital non soldé ({capital})") + return rows + + def total_interest(self, rows: list[Installment] | None = None) -> Decimal: + """Intérêts effectivement payés + intérêts capitalisés pendant le différé.""" + rows = rows if rows is not None else self.schedule() + return sum( + (r.interest + r.capitalised_interest for r in rows), Decimal(0) + ) + + def total_credit_cost(self, rows: list[Installment] | None = None) -> Decimal: + """Coût complet du crédit = intérêts + frais initiaux (hors assurance).""" + return self.total_interest(rows) + self.upfront_fees + + def total_paid(self, rows: list[Installment] | None = None) -> Decimal: + rows = rows if rows is not None else self.schedule() + return sum((r.payment for r in rows), Decimal(0)) + self.upfront_fees + + def taeg(self, rows: list[Installment] | None = None) -> Decimal: + """TAEG actuariel annuel, frais initiaux inclus, assurance exclue.""" + rows = rows if rows is not None else self.schedule() + flows = [(Decimal(r.rank) / MONTHS_PER_YEAR, r.payment) + for r in rows if r.payment != 0] + net = self.borrowed_capital - self.upfront_fees + return solve_taeg(net, flows) + + +def solve_taeg(net_capital: Decimal, flows: list[tuple[Decimal, Decimal]]) -> Decimal: + """Résout `net_capital = somme(montant / (1+t)^annees)` par dichotomie. + + Dichotomie plutôt que Newton : la fonction est monotone décroissante en `t` + sur [0, 1], donc la dichotomie converge sans dérivée ni cas pathologique, et + 100 itérations suffisent largement pour une précision au 1e-12. + """ + total = sum((amount for _, amount in flows), Decimal(0)) + if total <= net_capital: + return Decimal(0) + + def present_value(rate: Decimal) -> Decimal: + base = Decimal(1) + rate + return sum( + (amount / base ** years for years, amount in flows), Decimal(0) + ) + + low, high = Decimal(0), Decimal(1) + while present_value(high) > net_capital: + high *= 2 + if high > 100: + raise ValueError("TAEG hors de portée (> 10000 %)") + + for _ in range(200): + mid = (low + high) / 2 + if present_value(mid) > net_capital: + low = mid + else: + high = mid + return (low + high) / 2 + + +# -------------------------------------------------------------------------- +# Vérification contre un vrai tableau d'amortissement +# -------------------------------------------------------------------------- + +RANK_RE = re.compile(r"^\d{3}$") +DATE_RE = re.compile(r"^\d{2}/\d{2}/\d{4}$") +AMOUNT_RE = re.compile(r"^[\d\s\u00a0\u202f]+,\d{2}$") + +PAGE_RE = re.compile(r"", re.S) +LINE_RE = re.compile(r']*\byMin="([\d.]+)"[^>]*>(.*?)', re.S) +WORD_RE = re.compile(r']*\bxMin="([\d.]+)"[^>]*>([^<]*)') + +# Sur une même échéance pdftotext place la date ~1,3 pt plus bas que les +# montants ; deux échéances sont séparées de ~12,6 pt. 3 pt regroupe donc la +# ligne sans mordre sur la suivante. +ROW_BAND_IN_POINTS = 3.0 + + +def parse_amount(text: str) -> Decimal: + return Decimal(re.sub(r"[\s\u00a0\u202f]", "", text).replace(",", ".")) + + +@dataclass(frozen=True) +class ReferenceRow: + rank: int + date: str + payment: Decimal + principal: Decimal + interest: Decimal + fees: Decimal + remaining_capital: Decimal + deferred_interest: Decimal + + +def parse_reference(path: str) -> list[ReferenceRow]: + """Lit les échéances d'un tableau d'amortissement PDF. + + On passe par `pdftotext -bbox-layout` et on regroupe les mots par bande + horizontale plutôt que par la sortie `-layout` : le document porte un + tampon pivoté à 90° qui, dans le rendu texte, écrase la ligne qu'il + croise (le rang 34 disparaissait). Avec les coordonnées, le tampon est + juste un mot de plus dans la bande, qu'aucun motif ne reconnaît. + """ + if path.lower().endswith(".pdf"): + raw = subprocess.run( + ["pdftotext", "-bbox-layout", path, "-"], + check=True, capture_output=True, text=True, encoding="utf-8", + ).stdout + else: + raw = open(path, encoding="utf-8").read() + + rows: list[ReferenceRow] = [] + for page in PAGE_RE.findall(raw): + cells = [ + (float(y_min), float(x_min), word) + for y_min, body in LINE_RE.findall(page) + for x_min, word in WORD_RE.findall(body) + ] + for band in group_by_band(cells): + row = read_row([word for _, _, word in sorted(band)]) + if row is not None: + rows.append(row) + return rows + + +def group_by_band(cells: list[tuple[float, float, str]]) -> list[list]: + """Regroupe les mots d'une page en bandes horizontales (une par échéance).""" + bands: list[list] = [] + top = None + for cell in sorted(cells): + if top is None or cell[0] - top > ROW_BAND_IN_POINTS: + bands.append([]) + top = cell[0] + bands[-1].append((cell[1], cell[0], cell[2])) + return bands + + +def read_row(words: list[str]) -> ReferenceRow | None: + """Reconstruit une échéance depuis les mots d'une bande, ou None.""" + # Le tampon pivoté tombe dans la marge de gauche : il peut précéder le rang + # une fois la bande triée par abscisse. On ancre donc sur le couple + # rang + date plutôt que sur la première position. + anchor = next( + ( + i + for i in range(len(words) - 1) + if RANK_RE.match(words[i]) and DATE_RE.match(words[i + 1]) + ), + None, + ) + if anchor is None: + return None + + # Les montants sont coupés au séparateur de milliers ("451", "005,29") : + # on recolle chaque groupe de chiffres au montant qui le suit. + amounts: list[Decimal] = [] + pending = "" + for word in words[anchor + 2:]: + if word.isdigit(): + pending += word + continue + if AMOUNT_RE.match(pending + word): + amounts.append(parse_amount(pending + word)) + pending = "" + if len(amounts) != 7: + return None + + return ReferenceRow( + rank=int(words[anchor]), + date=words[anchor + 1], + payment=amounts[0], + principal=amounts[1], + interest=amounts[2], + fees=amounts[3], + remaining_capital=amounts[4], + deferred_interest=amounts[5], + ) + + +def verify(path: str, annual_rate: Decimal) -> int: + """Rejoue la phase d'amortissement du tableau réel et compare ligne à ligne. + + Le différé du tableau de référence court sur un déblocage progressif dont + les dates ne figurent pas au document : ses intérêts sont calculés en + nombre de jours exacts sur 366 et ne sont pas reproductibles à partir des + seules données de l'offre. On repart donc du capital tel qu'il figure à la + fin du différé, et on vérifie les 297 échéances d'amortissement. + """ + reference = parse_reference(path) + if not reference: + print(f"aucune ligne d'échéance trouvée dans {path}", file=sys.stderr) + return 1 + + amortising = [r for r in reference if r.payment != 0 and r.principal != 0] + start_index = reference.index(amortising[0]) + opening_capital = reference[start_index - 1].remaining_capital + + offer = LoanOffer( + label="référence", + borrowed_capital=opening_capital, + annual_nominal_rate=annual_rate, + total_duration_in_months=len(amortising), + ) + computed = offer.schedule() + + print(f"Vérification de {path}") + print(f" capital en début d'amortissement : {opening_capital} EUR") + print(f" taux nominal annuel : {annual_rate * 100} %") + print(f" taux mensuel proportionnel : {offer.monthly_rate}") + print(f" échéances comparées : {len(amortising)}") + print(f" mensualité calculée : {offer.monthly_repayment} EUR") + print(f" mensualité du tableau : {amortising[0].payment} EUR") + + mismatches = 0 + for expected, actual in zip(amortising, computed): + for name, want, got in ( + ("mensualité", expected.payment, actual.payment), + ("capital amorti", expected.principal, actual.principal), + ("intérêts", expected.interest, actual.interest), + ("capital restant dû", expected.remaining_capital, actual.remaining_capital), + ): + if want != got: + mismatches += 1 + if mismatches <= 10: + print( + f" ÉCART rang {expected.rank} ({expected.date}) {name} : " + f"tableau {want} / calculé {got}" + ) + + # Recoupement avec la ligne TOTAL GENERAL du document, qui ne compte que la + # phase d'amortissement : les intérêts du différé sont déjà capitalisés + # dans le capital de départ. + checks = ( + ("capital amorti", + sum((r.principal for r in amortising), Decimal(0)), + sum((r.principal for r in computed), Decimal(0))), + ("intérêts totaux", + sum((r.interest for r in amortising), Decimal(0)), + offer.total_interest(computed)), + ("total à recouvrer", + sum((r.payment for r in amortising), Decimal(0)), + offer.total_paid(computed)), + ) + for name, want, got in checks: + flag = "OK " if want == got else "ÉCART" + print(f" {flag} {name:<18} : tableau {want} / calculé {got}") + if want != got: + mismatches += 1 + + if mismatches: + print(f"ÉCHEC : {mismatches} écart(s)") + return 1 + print(f"OK : {len(amortising)} échéances identiques au centime près") + return 0 + + +# -------------------------------------------------------------------------- +# Génération des jeux de test +# -------------------------------------------------------------------------- + +def summarise(offer: LoanOffer) -> dict: + rows = offer.schedule() + return { + "id": offer.id, + "label": offer.label, + "borrowed_capital": str(offer.borrowed_capital), + "annual_nominal_rate": str(offer.annual_nominal_rate), + "annual_nominal_rate_percent": str(offer.annual_nominal_rate * 100), + "total_duration_in_months": offer.total_duration_in_months, + "deferral_in_months": offer.deferral_in_months, + "deferral_kind": offer.deferral_kind, + "upfront_fees": str(offer.upfront_fees), + "monthly_rate": str(offer.monthly_rate), + "monthly_rate_rounded_1e12": str( + offer.monthly_rate.quantize(Decimal("1e-12"), rounding=ROUND_HALF_UP) + ), + "amortisation_in_months": offer.amortisation_in_months, + "amortised_capital": str(offer.amortised_capital), + "monthly_repayment": str(offer.monthly_repayment), + "last_installment": str(rows[-1].payment), + "total_interest": str(offer.total_interest(rows)), + "total_credit_cost": str(offer.total_credit_cost(rows)), + "total_paid": str(offer.total_paid(rows)), + "taeg": str(offer.taeg(rows).quantize(Decimal("1e-8"), rounding=ROUND_HALF_UP)), + "taeg_percent": str( + (offer.taeg(rows) * 100).quantize(Decimal("1e-6"), rounding=ROUND_HALF_UP) + ), + "schedule": [r.as_dict() for r in rows], + } + + +def print_summary(offer: LoanOffer) -> None: + data = summarise(offer) + rows = offer.schedule() + print(f"\n=== {offer.label} ===") + print(f" capital emprunté : {data['borrowed_capital']} EUR") + print(f" taux nominal annuel : {data['annual_nominal_rate_percent']} %") + print(f" durée totale : {data['total_duration_in_months']} mois") + print(f" différé : {data['deferral_in_months']} mois " + f"({data['deferral_kind']})") + print(f" taux mensuel proportionnel: {data['monthly_rate_rounded_1e12']}") + print(f" capital amorti (base) : {data['amortised_capital']} EUR") + print(f" mensualité : {data['monthly_repayment']} EUR " + f"x {data['amortisation_in_months']}") + print(f" dernière échéance : {data['last_installment']} EUR") + print(f" intérêts totaux : {data['total_interest']} EUR") + print(f" frais initiaux : {data['upfront_fees']} EUR") + print(f" coût complet du crédit : {data['total_credit_cost']} EUR") + print(f" total décaissé : {data['total_paid']} EUR") + print(f" TAEG : {data['taeg_percent']} %") + print(" premières échéances :") + for row in rows[:3]: + print(f" {row.rank:>3} {row.phase:<14} éch {row.payment:>9} " + f"cap {row.principal:>9} int {row.interest:>8} CRD {row.remaining_capital:>12}") + print(" dernières échéances :") + for row in rows[-2:]: + print(f" {row.rank:>3} {row.phase:<14} éch {row.payment:>9} " + f"cap {row.principal:>9} int {row.interest:>8} CRD {row.remaining_capital:>12}") + + +# -------------------------------------------------------------------------- +# Rendu Rust +# -------------------------------------------------------------------------- + +RUST_HEADER = '''\ +//! Jeux de test de référence. **Fichier généré, ne pas éditer à la main.** +//! +//! Régénérer avec : +//! `python3 scripts/loan_schedule.py generate --rust {path}` +//! +//! Conventions (taux mensuel proportionnel, arrondi ROUND_HALF_UP au centime, +//! dernière échéance ajustée) vérifiées contre un tableau d'amortissement réel +//! Caisse d'Épargne 2020, et recoupées avec le simulateur de l'ANIL le +//! 2026-08-03. Voir la docstring de `scripts/loan_schedule.py`. + +// Les montants sont groupés `euros_centimes` (`149_563_23`) pour rester +// lisibles quand un test échoue. Clippy prend les groupes finaux `_8`, `_16`, +// `_32` et `_64` pour des suffixes de type mal écrits ; l'exception s'arrête à +// ce fichier généré. +#![allow(clippy::mistyped_literal_suffixes)] + +use rust_decimal::Decimal; + +use crate::euros::Euros; + +/// Une ligne de l'échéancier de référence. +// Tous les champs ne sont pas encore lus : les jeux de test précèdent les +// fonctions qu'ils valideront. +#[allow(dead_code)] +#[derive(Debug, PartialEq, Eq)] +pub struct MonthlyInstallment {{ + pub rank: u64, + pub principal: Euros, + pub interest: Euros, + pub remaining_capital: Euros, +}} + +/// Une offre de prêt et les valeurs de référence qu'elle doit produire. +#[allow(dead_code)] +#[derive(Debug, PartialEq, Eq)] +pub struct LoanOfferFixture {{ + pub label: &'static str, + pub borrowed_capital: Euros, + pub annual_nominal_rate: Decimal, + pub total_duration_in_months: u64, + pub deferral_in_months: u64, + pub monthly_repayment: Euros, + pub total_credit_cost: Euros, + pub total_paid: Euros, + pub taeg: Decimal, + pub schedule: &'static [MonthlyInstallment], +}} +''' + + +def rust_string(text: str) -> str: + """Littéral de chaîne Rust. Les labels contiennent des apostrophes et des + tirets cadratins, mais jamais de guillemet ni d'antislash.""" + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def rust_euros(value: Decimal) -> str: + """`Euros::from_cents_as_i64(150_000_00)` : euros groupés par 3, puis les + centimes, comme le reste du code.""" + cents = int(to_cents(value).scaleb(2)) + sign = "-" if cents < 0 else "" + digits = str(abs(cents)).zfill(3) + whole, hundredths = digits[:-2], digits[-2:] + groups = [] + while len(whole) > 3: + groups.insert(0, whole[-3:]) + whole = whole[:-3] + groups.insert(0, whole) + return f"Euros::from_cents_as_i64({sign}{'_'.join(groups)}_{hundredths})" + + +def rust_decimal(value: Decimal) -> str: + """`Decimal::new(mantisse, échelle)`, la forme exacte utilisée par le crate. + + `Decimal::new` refuse une échelle négative, donc on requantifie les valeurs + dont l'exposant est positif (`1E+2`) avant d'en extraire la mantisse.""" + sign, digits, exponent = value.as_tuple() + if exponent > 0: + value = value.quantize(Decimal(1)) + sign, digits, exponent = value.as_tuple() + mantissa = int("".join(str(d) for d in digits)) + if sign: + mantissa = -mantissa + return f"Decimal::new({mantissa}, {-exponent})" + + +def render_rust(offers: list[LoanOffer], path: str) -> str: + chunks = [RUST_HEADER.format(path=path)] + + for offer in offers: + rows = offer.schedule() + lines = [ + f"\nconst {offer.id.upper()}_SCHEDULE: " + f"[MonthlyInstallment; {len(rows)}] = [" + ] + for row in rows: + lines.append( + " MonthlyInstallment {\n" + f" rank: {row.rank},\n" + f" principal: {rust_euros(row.principal)},\n" + f" interest: {rust_euros(row.interest)},\n" + f" remaining_capital: {rust_euros(row.remaining_capital)},\n" + " }," + ) + lines.append("];\n") + chunks.append("\n".join(lines)) + + for offer in offers: + rows = offer.schedule() + chunks.append( + f"\npub fn {offer.id}() -> LoanOfferFixture {{\n" + " LoanOfferFixture {\n" + f" label: {rust_string(offer.label)},\n" + f" borrowed_capital: {rust_euros(offer.borrowed_capital)},\n" + f" annual_nominal_rate: {rust_decimal(offer.annual_nominal_rate)},\n" + f" total_duration_in_months: {offer.total_duration_in_months},\n" + f" deferral_in_months: {offer.deferral_in_months},\n" + f" monthly_repayment: {rust_euros(offer.monthly_repayment)},\n" + f" total_credit_cost: {rust_euros(offer.total_credit_cost(rows))},\n" + f" total_paid: {rust_euros(offer.total_paid(rows))},\n" + " taeg: " + f"{rust_decimal(offer.taeg(rows).quantize(Decimal('1e-8'), rounding=ROUND_HALF_UP))},\n" + f" schedule: &{offer.id.upper()}_SCHEDULE,\n" + " }\n" + "}\n" + ) + + calls = "".join(f" {offer.id}(),\n" for offer in offers) + chunks.append( + f"\npub fn all() -> [LoanOfferFixture; {len(offers)}] {{\n" + f" [\n{calls} ]\n" + "}\n" + ) + + return "".join(chunks) + + +def sample_offers() -> list[LoanOffer]: + """Les trois offres anonymisées servant de jeux de test à `immo-core`.""" + return [ + LoanOffer( + id="offre_a", + label="Offre A — 150 000 EUR, 3,41 %, 20 ans, sans différé", + borrowed_capital=Decimal("150000.00"), + annual_nominal_rate=Decimal("0.0341"), + total_duration_in_months=240, + ), + LoanOffer( + id="offre_b_differe_total", + label="Offre B — 439 000 EUR, 2,45 %, 25 ans, 6 mois de différé total", + borrowed_capital=Decimal("439000.00"), + annual_nominal_rate=Decimal("0.0245"), + total_duration_in_months=300, + deferral_in_months=6, + deferral_kind=DeferralKind.TOTAL, + ), + LoanOffer( + id="offre_b_differe_partiel", + label="Offre B' — 439 000 EUR, 2,45 %, 25 ans, 6 mois de différé partiel", + borrowed_capital=Decimal("439000.00"), + annual_nominal_rate=Decimal("0.0245"), + total_duration_in_months=300, + deferral_in_months=6, + deferral_kind=DeferralKind.PARTIAL, + ), + LoanOffer( + id="offre_c_ptz", + label="Offre C — PTZ 95 000 EUR, 0 %, 10 ans, sans différé", + borrowed_capital=Decimal("95000.00"), + annual_nominal_rate=Decimal("0"), + total_duration_in_months=120, + ), + ] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="command", required=True) + + check = sub.add_parser("verify", help="comparer à un tableau d'amortissement réel") + check.add_argument("schedule", help="sortie de `pdftotext -layout` du tableau") + check.add_argument("--annual-rate", default="0.0115", + help="taux nominal annuel du tableau (défaut 0.0115)") + + gen = sub.add_parser("generate", help="produire les jeux de test anonymisés") + gen.add_argument("--json", help="écrire les échéanciers complets dans ce fichier") + gen.add_argument("--rust", help="écrire le module de fixtures Rust dans ce fichier") + + args = parser.parse_args() + + if args.command == "verify": + return verify(args.schedule, Decimal(args.annual_rate)) + + offers = sample_offers() + for offer in offers: + print_summary(offer) + if args.json: + with open(args.json, "w", encoding="utf-8") as handle: + json.dump([summarise(o) for o in offers], handle, + ensure_ascii=False, indent=2) + handle.write("\n") + print(f"\néchéanciers complets écrits dans {args.json}") + if args.rust: + with open(args.rust, "w", encoding="utf-8") as handle: + handle.write(render_rust(offers, args.rust)) + print(f"\nmodule de fixtures Rust écrit dans {args.rust}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())