#!/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())