747 lines
30 KiB
Python
747 lines
30 KiB
Python
|
|
#!/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())
|