should-i-rent/scripts/loan_schedule.py

747 lines
30 KiB
Python
Raw Normal View History

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