Compare commits

..

No commits in common. "8bf701e0108cf71c292a95a5cb0d0e1ff6e04c6c" and "f1ba015cc32fe8390158779723fad9fc1f93a2d6" have entirely different histories.

5 changed files with 50 additions and 176 deletions

View file

@ -17,7 +17,6 @@
use rust_decimal::Decimal;
use crate::euros::Euros;
use crate::interest_rate::InterestRate;
/// 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
@ -37,27 +36,13 @@ pub struct MonthlyInstallment {
pub struct LoanOfferFixture {
pub label: &'static str,
pub borrowed_capital: Euros,
pub annual_nominal_rate: InterestRate,
pub annual_nominal_rate: Decimal,
pub total_duration_in_months: u64,
pub deferral_in_months: u64,
/// Capital sur lequel court l'amortissement, après capitalisation des
/// intérêts du différé. Égal à `borrowed_capital` hors différé total.
///
/// C'est **ce** capital, et non `borrowed_capital`, qui alimente la formule
/// de mensualité. Sur l'offre B, le différé total porte les 439 000 EUR
/// empruntés à 444 405,27 EUR amortissables.
pub amortised_capital: Euros,
/// Nombre d'échéances amortissantes, différé exclu. C'est le `n` de la
/// formule de mensualité, à ne pas confondre avec `total_duration_in_months`.
///
/// Alimenter la formule avec `borrowed_capital` et `total_duration_in_months`
/// sur l'offre B donne 1 958,39 EUR au lieu des 2 011,86 EUR du tableau :
/// l'écart est un cumul des deux confusions, pas une dérive d'arrondi.
pub amortisation_in_months: u64,
pub monthly_repayment: Euros,
pub total_credit_cost: Euros,
pub total_paid: Euros,
pub taeg: InterestRate,
pub taeg: Decimal,
pub schedule: &'static [MonthlyInstallment],
}
@ -5837,15 +5822,13 @@ pub fn offre_a() -> LoanOfferFixture {
LoanOfferFixture {
label: "Offre A — 150 000 EUR, 3,41 %, 20 ans, sans différé",
borrowed_capital: Euros::from_cents_as_i64(150_000_00),
annual_nominal_rate: InterestRate::from_fraction(Decimal::new(341, 4)),
annual_nominal_rate: Decimal::new(341, 4),
total_duration_in_months: 240,
deferral_in_months: 0,
amortised_capital: Euros::from_cents_as_i64(150_000_00),
amortisation_in_months: 240,
monthly_repayment: Euros::from_cents_as_i64(863_02),
total_credit_cost: Euros::from_cents_as_i64(57_124_30),
total_paid: Euros::from_cents_as_i64(207_124_30),
taeg: InterestRate::from_fraction(Decimal::new(3463804, 8)),
taeg: Decimal::new(3463804, 8),
schedule: &OFFRE_A_SCHEDULE,
}
}
@ -5854,15 +5837,13 @@ pub fn offre_b_differe_total() -> LoanOfferFixture {
LoanOfferFixture {
label: "Offre B — 439 000 EUR, 2,45 %, 25 ans, 6 mois de différé total",
borrowed_capital: Euros::from_cents_as_i64(439_000_00),
annual_nominal_rate: InterestRate::from_fraction(Decimal::new(245, 4)),
annual_nominal_rate: Decimal::new(245, 4),
total_duration_in_months: 300,
deferral_in_months: 6,
amortised_capital: Euros::from_cents_as_i64(444_405_27),
amortisation_in_months: 294,
monthly_repayment: Euros::from_cents_as_i64(2_011_86),
total_credit_cost: Euros::from_cents_as_i64(152_486_18),
total_paid: Euros::from_cents_as_i64(591_486_18),
taeg: InterestRate::from_fraction(Decimal::new(2477699, 8)),
taeg: Decimal::new(2477699, 8),
schedule: &OFFRE_B_DIFFERE_TOTAL_SCHEDULE,
}
}
@ -5871,15 +5852,13 @@ pub fn offre_b_differe_partiel() -> LoanOfferFixture {
LoanOfferFixture {
label: "Offre B' — 439 000 EUR, 2,45 %, 25 ans, 6 mois de différé partiel",
borrowed_capital: Euros::from_cents_as_i64(439_000_00),
annual_nominal_rate: InterestRate::from_fraction(Decimal::new(245, 4)),
annual_nominal_rate: Decimal::new(245, 4),
total_duration_in_months: 300,
deferral_in_months: 6,
amortised_capital: Euros::from_cents_as_i64(439_000_00),
amortisation_in_months: 294,
monthly_repayment: Euros::from_cents_as_i64(1_987_39),
total_credit_cost: Euros::from_cents_as_i64(150_669_69),
total_paid: Euros::from_cents_as_i64(589_669_69),
taeg: InterestRate::from_fraction(Decimal::new(2477699, 8)),
taeg: Decimal::new(2477699, 8),
schedule: &OFFRE_B_DIFFERE_PARTIEL_SCHEDULE,
}
}
@ -5888,15 +5867,13 @@ pub fn offre_c_ptz() -> LoanOfferFixture {
LoanOfferFixture {
label: "Offre C — PTZ 95 000 EUR, 0 %, 10 ans, sans différé",
borrowed_capital: Euros::from_cents_as_i64(95_000_00),
annual_nominal_rate: InterestRate::from_fraction(Decimal::new(0, 0)),
annual_nominal_rate: Decimal::new(0, 0),
total_duration_in_months: 120,
deferral_in_months: 0,
amortised_capital: Euros::from_cents_as_i64(95_000_00),
amortisation_in_months: 120,
monthly_repayment: Euros::from_cents_as_i64(791_67),
total_credit_cost: Euros::from_cents_as_i64(0_00),
total_paid: Euros::from_cents_as_i64(95_000_00),
taeg: InterestRate::from_fraction(Decimal::new(0, 8)),
taeg: Decimal::new(0, 8),
schedule: &OFFRE_C_PTZ_SCHEDULE,
}
}

View file

@ -1,73 +0,0 @@
use std::fmt::Display;
use rust_decimal::Decimal;
#[derive(Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord)]
pub struct InterestRate {
rate_as_fraction: Decimal,
}
impl InterestRate {
pub const ZERO: Self = InterestRate {
rate_as_fraction: Decimal::ZERO,
};
pub fn from_percentage(rate_in_percent: Decimal) -> Self {
InterestRate {
rate_as_fraction: rate_in_percent / Decimal::ONE_HUNDRED,
}
}
pub const fn from_fraction(rate_as_fraction: Decimal) -> Self {
InterestRate { rate_as_fraction }
}
pub const fn as_fraction(self) -> Decimal {
self.rate_as_fraction
}
pub fn as_percentage(self) -> Decimal {
(self.rate_as_fraction * Decimal::ONE_HUNDRED).normalize()
}
}
impl Display for InterestRate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} %", self.as_percentage())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_from_percentage_yields_correct_rate() {
assert_eq!(
InterestRate::from_percentage(Decimal::new(415, 2)).as_fraction(),
Decimal::new(415, 4)
);
}
#[test]
fn create_from_fraction_yields_correct_rate() {
assert_eq!(
InterestRate::from_fraction(Decimal::new(415, 4)).as_fraction(),
Decimal::new(415, 4)
);
}
#[test]
fn create_from_fraction_is_properly_output_as_percentage() {
assert_eq!(
InterestRate::from_fraction(Decimal::new(314, 4)).as_percentage(),
Decimal::new(314, 2)
)
}
#[test]
fn display_works_as_expected_with_no_trailing_zeros() {
let interest_rate = InterestRate::from_fraction(Decimal::new(3145, 5));
assert_eq!(format!("{}", interest_rate), "3.145 %");
}
}

View file

@ -1,5 +1,4 @@
pub mod euros;
pub mod interest_rate;
pub mod loan_offer;
#[cfg(test)]

View file

@ -3,12 +3,11 @@ use rust_decimal::MathematicalOps;
use thiserror::Error;
use crate::euros::Euros;
use crate::interest_rate::InterestRate;
#[derive(Debug, PartialEq, Eq, Error, Clone)]
pub enum LoanOfferError {
#[error("loan cannot have a negative interest rate: {0}")]
NegativeInterestRate(InterestRate),
NegativeInterestRate(Decimal),
#[error("loan duration cannot be zero")]
ZeroLoanDuration,
#[error("borrowed capital should be strictly positive: {0}")]
@ -16,7 +15,7 @@ pub enum LoanOfferError {
#[error("borrowed capital should be lower than {MAX_BORROWED_CAPITAL}: {0}")]
MaxBorrowedCapitalExceeded(Euros),
#[error("annual nominal rate should be lower than {MAX_ANNUAL_NOMINAL_RATE}: {0}")]
MaxAnnualNominalRateExceeded(InterestRate),
MaxAnnualNominalRateExceeded(Decimal),
#[error("loan duration in months should be lower than {MAX_LOAN_DURATION_IN_MONTHS}: {0}")]
MaxLoanDurationExceeded(u64),
#[error("loan cannot amortise")]
@ -27,8 +26,7 @@ pub enum LoanOfferError {
pub const MAX_BORROWED_CAPITAL: Euros = Euros::from_cents_as_i64(999_999_999_99);
/// 99% nominal rate will hopefully never happen
pub const MAX_ANNUAL_NOMINAL_RATE: InterestRate =
InterestRate::from_fraction(Decimal::from_parts(99, 0, 0, false, 2));
pub const MAX_ANNUAL_NOMINAL_RATE: Decimal = Decimal::from_parts(99, 0, 0, false, 2);
/// Loan duration is theoretically constrained to max 25y in France, let's use 40 to be sure
pub const MAX_LOAN_DURATION_IN_MONTHS: u64 = 40 * 12;
@ -36,17 +34,17 @@ pub const MAX_LOAN_DURATION_IN_MONTHS: u64 = 40 * 12;
#[derive(Debug, PartialEq, Eq)]
pub struct LoanOffer {
borrowed_capital: Euros,
annual_nominal_rate: InterestRate,
annual_nominal_rate: Decimal,
loan_duration_in_months: u64,
}
impl LoanOffer {
pub fn new(
borrowed_capital: Euros,
annual_nominal_rate: InterestRate,
annual_nominal_rate: Decimal,
loan_duration_in_months: u64,
) -> Result<Self, LoanOfferError> {
if annual_nominal_rate < InterestRate::ZERO {
if annual_nominal_rate < Decimal::ZERO {
Err(LoanOfferError::NegativeInterestRate(annual_nominal_rate))
} else if annual_nominal_rate > MAX_ANNUAL_NOMINAL_RATE {
Err(LoanOfferError::MaxAnnualNominalRateExceeded(
@ -81,8 +79,12 @@ impl LoanOffer {
}
}
fn proportional_monthly_interest_rate(&self) -> Decimal {
self.annual_nominal_rate.as_fraction() / Decimal::from(12)
pub fn annual_nominal_rate_in_percent(&self) -> Decimal {
self.annual_nominal_rate * Decimal::ONE_HUNDRED
}
pub fn proportional_monthly_interest_rate(&self) -> Decimal {
self.annual_nominal_rate / Decimal::from(12)
}
pub fn monthly_repayment(&self) -> Euros {
@ -108,32 +110,23 @@ mod tests {
#[test]
fn creating_loan_offer_with_negative_interest_rate_returns_negative_interest_rate_error() {
let annual_nominal_rate = InterestRate::from_percentage(Decimal::new(-45, 2));
let ret = LoanOffer::new(Euros::from_cents_as_i64(123), annual_nominal_rate, 60);
let ret = LoanOffer::new(Euros::from_cents_as_i64(123), Decimal::new(-45, 2), 60);
assert_eq!(
ret,
Err(LoanOfferError::NegativeInterestRate(annual_nominal_rate))
Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2)))
);
}
#[test]
fn creating_loan_offer_with_null_loan_duration_returns_zero_loan_duration_error() {
let ret = LoanOffer::new(
Euros::from_cents_as_i64(123),
InterestRate::from_percentage(Decimal::new(45, 2)),
0,
);
let ret = LoanOffer::new(Euros::from_cents_as_i64(123), Decimal::new(45, 2), 0);
assert_eq!(ret, Err(LoanOfferError::ZeroLoanDuration));
}
#[test]
fn creating_loan_offer_with_negative_borrowed_capital_returns_non_positive_borrowed_capital_error()
{
let ret = LoanOffer::new(
Euros::from_cents_as_i64(-1),
InterestRate::from_percentage(Decimal::new(45, 2)),
15,
);
let ret = LoanOffer::new(Euros::from_cents_as_i64(-1), Decimal::new(45, 2), 15);
assert_eq!(
ret,
Err(LoanOfferError::NonPositiveBorrowedCapital(
@ -146,19 +139,14 @@ mod tests {
fn creating_loan_offer_with_borrowed_capital_higher_than_max_borrowed_capital_returns_max_borrowed_capital_exceeded_error()
{
let euros = MAX_BORROWED_CAPITAL + Euros::from_cents_as_i64(1);
let ret = LoanOffer::new(
euros,
InterestRate::from_percentage(Decimal::new(45, 2)),
25,
);
let ret = LoanOffer::new(euros, Decimal::new(45, 2), 25);
assert_eq!(ret, Err(LoanOfferError::MaxBorrowedCapitalExceeded(euros)));
}
#[test]
fn creating_loan_offer_with_annual_nominal_rate_higher_than_max_annual_nominal_rate_returns_max_annual_nominal_rate_exceeded_error()
{
let annual_nominal_rate =
InterestRate::from_fraction(MAX_ANNUAL_NOMINAL_RATE.as_fraction() + Decimal::new(1, 4));
let annual_nominal_rate = MAX_ANNUAL_NOMINAL_RATE + Decimal::new(1, 2);
let ret = LoanOffer::new(
Euros::from_cents_as_i64(100_000_00),
annual_nominal_rate,
@ -178,7 +166,7 @@ mod tests {
let loan_duration_in_months = MAX_LOAN_DURATION_IN_MONTHS + 1;
let ret = LoanOffer::new(
Euros::from_cents_as_i64(100_000_00),
InterestRate::from_percentage(Decimal::new(314, 2)),
Decimal::new(314, 4),
loan_duration_in_months,
);
assert_eq!(
@ -192,11 +180,7 @@ mod tests {
#[test]
fn creating_loan_offer_with_null_borrowed_capital_returns_non_positive_borrowed_capital_error()
{
let ret = LoanOffer::new(
Euros::from_cents_as_i64(0),
InterestRate::from_percentage(Decimal::new(45, 2)),
15,
);
let ret = LoanOffer::new(Euros::from_cents_as_i64(0), Decimal::new(45, 2), 15);
assert_eq!(
ret,
Err(LoanOfferError::NonPositiveBorrowedCapital(
@ -209,7 +193,7 @@ mod tests {
fn creating_loan_offer_that_cannot_amortise_returns_loan_cannot_amortise_error() {
let ret = LoanOffer::new(
Euros::from_cents_as_i64(439_000_00),
InterestRate::from_percentage(Decimal::from(99)),
Decimal::new(99, 2),
480,
);
assert_eq!(ret, Err(LoanOfferError::LoanCannotAmortise));
@ -217,13 +201,12 @@ mod tests {
#[test]
fn creating_loan_offer_with_correct_inputs_works() {
let annual_nominal_rate = InterestRate::from_percentage(Decimal::new(45, 2));
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), annual_nominal_rate, 15);
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), Decimal::new(45, 2), 15);
assert_eq!(
ret,
Ok(LoanOffer {
borrowed_capital: Euros::from_cents_as_i64(10),
annual_nominal_rate,
annual_nominal_rate: Decimal::new(45, 2),
loan_duration_in_months: 15
})
);
@ -239,17 +222,28 @@ mod tests {
#[test]
fn creating_loan_offer_with_null_interest_rate_works() {
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), InterestRate::ZERO, 15);
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), Decimal::ZERO, 15);
assert_eq!(
ret,
Ok(LoanOffer {
borrowed_capital: Euros::from_cents_as_i64(10),
annual_nominal_rate: InterestRate::ZERO,
annual_nominal_rate: Decimal::ZERO,
loan_duration_in_months: 15
})
);
}
#[test]
fn conversion_to_annual_nominal_rate_in_percent_works() {
let offer = LoanOffer::new(
Euros::from_cents_as_i64(150_000_00),
Decimal::new(4, 2),
300,
)
.unwrap();
assert_eq!(offer.annual_nominal_rate_in_percent(), Decimal::new(400, 2));
}
#[test]
fn monthly_repayment_works_for_standard_loan_offer() {
let fixture = offre_a();

View file

@ -552,7 +552,6 @@ RUST_HEADER = '''\
use rust_decimal::Decimal;
use crate::euros::Euros;
use crate::interest_rate::InterestRate;
/// 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
@ -572,27 +571,13 @@ pub struct MonthlyInstallment {{
pub struct LoanOfferFixture {{
pub label: &'static str,
pub borrowed_capital: Euros,
pub annual_nominal_rate: InterestRate,
pub annual_nominal_rate: Decimal,
pub total_duration_in_months: u64,
pub deferral_in_months: u64,
/// Capital sur lequel court l'amortissement, après capitalisation des
/// intérêts du différé. Égal à `borrowed_capital` hors différé total.
///
/// C'est **ce** capital, et non `borrowed_capital`, qui alimente la formule
/// de mensualité. Sur l'offre B, le différé total porte les 439 000 EUR
/// empruntés à 444 405,27 EUR amortissables.
pub amortised_capital: Euros,
/// Nombre d'échéances amortissantes, différé exclu. C'est le `n` de la
/// formule de mensualité, à ne pas confondre avec `total_duration_in_months`.
///
/// Alimenter la formule avec `borrowed_capital` et `total_duration_in_months`
/// sur l'offre B donne 1 958,39 EUR au lieu des 2 011,86 EUR du tableau :
/// l'écart est un cumul des deux confusions, pas une dérive d'arrondi.
pub amortisation_in_months: u64,
pub monthly_repayment: Euros,
pub total_credit_cost: Euros,
pub total_paid: Euros,
pub taeg: InterestRate,
pub taeg: Decimal,
pub schedule: &'static [MonthlyInstallment],
}}
'''
@ -634,12 +619,6 @@ def rust_decimal(value: Decimal) -> str:
return f"Decimal::new({mantissa}, {-exponent})"
def rust_interest_rate(value: Decimal) -> str:
"""`InterestRate::from_fraction(...)`. Les taux sont stockés en fraction
(0,0341 pour 3,41 %), comme dans `immo-core`."""
return f"InterestRate::from_fraction({rust_decimal(value)})"
def render_rust(offers: list[LoanOffer], path: str) -> str:
chunks = [RUST_HEADER.format(path=path)]
@ -668,16 +647,14 @@ def render_rust(offers: list[LoanOffer], path: str) -> str:
" LoanOfferFixture {\n"
f" label: {rust_string(offer.label)},\n"
f" borrowed_capital: {rust_euros(offer.borrowed_capital)},\n"
f" annual_nominal_rate: {rust_interest_rate(offer.annual_nominal_rate)},\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" amortised_capital: {rust_euros(offer.amortised_capital)},\n"
f" amortisation_in_months: {offer.amortisation_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_interest_rate(offer.taeg(rows).quantize(Decimal('1e-8'), rounding=ROUND_HALF_UP))},\n"
f"{rust_decimal(offer.taeg(rows).quantize(Decimal('1e-8'), rounding=ROUND_HALF_UP))},\n"
f" schedule: &{offer.id.upper()}_SCHEDULE,\n"
" }\n"
"}\n"