Add reference amortisation generator for known-answer tests
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 <noreply@anthropic.com>
This commit is contained in:
parent
f1e596f475
commit
85b0836d6e
2 changed files with 749 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -20,3 +20,6 @@
|
|||
# Local environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Python tooling
|
||||
__pycache__/
|
||||
|
|
|
|||
746
scripts/loan_schedule.py
Normal file
746
scripts/loan_schedule.py
Normal file
|
|
@ -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"<page\b.*?</page>", re.S)
|
||||
LINE_RE = re.compile(r'<line\b[^>]*\byMin="([\d.]+)"[^>]*>(.*?)</line>', re.S)
|
||||
WORD_RE = re.compile(r'<word\b[^>]*\bxMin="([\d.]+)"[^>]*>([^<]*)</word>')
|
||||
|
||||
# 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())
|
||||
Loading…
Add table
Reference in a new issue