loan-simu 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
loan_simu/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Renovation loan simulator for France."""
2
+
3
+ __version__ = "0.2.0"
loan_simu/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .ui.app import main
2
+
3
+ main()
loan_simu/core.py ADDED
@@ -0,0 +1,387 @@
1
+ """Calculation engine for French renovation loans (prêt travaux).
2
+
3
+ Conventions follow French banking practice:
4
+ - the monthly rate is the nominal annual rate / 12 (taux proportionnel);
5
+ - the APR (TAEG) is the actuarial annual rate that equates the net amount
6
+ received with every payment due (interest, mandatory insurance, fees);
7
+ - early repayment penalties (IRA) follow the Code de la consommation.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field, replace
12
+
13
+ CONSUMER = "consumer" # prêt personnel travaux (crédit à la consommation)
14
+ MORTGAGE = "mortgage" # prêt immobilier travaux (garantie : caution / hypothèque)
15
+ ECO_PTZ = "eco_ptz" # éco-prêt à taux zéro
16
+
17
+ DEFERRAL_NONE = "none"
18
+ DEFERRAL_PARTIAL = "partial" # différé partiel : only interest + insurance are paid
19
+ DEFERRAL_TOTAL = "total" # différé total : interest is capitalised
20
+
21
+ REDUCE_DURATION = "duration"
22
+ REDUCE_PAYMENT = "payment"
23
+
24
+ MAX_DEBT_RATIO = 35.0 # HCSF recommendation, insurance included
25
+ MAX_MORTGAGE_MONTHS = 25 * 12 # HCSF recommendation
26
+ CONSUMER_LOAN_CEILING = 75_000 # above this, a renovation loan is a real-estate credit
27
+ CONSUMER_IRA_THRESHOLD = 10_000 # no penalty below this amount repaid over 12 months
28
+
29
+ # Taux d'usure published quarterly by the Banque de France. Update every quarter.
30
+ USURY_PERIOD = "Q4 2026 (real-estate) / Q3 2026 (consumer)"
31
+ USURY_MORTGAGE = [(120, 4.09), (240, 4.68), (None, 5.40)] # (duration < months, rate)
32
+ USURY_CONSUMER = [(3_000, 23.52), (6_000, 15.67), (None, 8.56)] # (amount <= €, rate)
33
+
34
+ # Éco-PTZ categories: (label, maximum amount, maximum duration in years).
35
+ ECO_PTZ_CATEGORIES = [
36
+ ("1 action – windows only", 7_000, 15),
37
+ ("1 action", 15_000, 15),
38
+ ("2 actions", 25_000, 15),
39
+ ("3 actions or more", 30_000, 15),
40
+ ("Global energy performance", 50_000, 20),
41
+ ("With MaPrimeRénov' (rénovation d'ampleur)", 50_000, 20),
42
+ ("Non-collective sanitation (ANC)", 10_000, 15),
43
+ ]
44
+
45
+
46
+ @dataclass
47
+ class LoanParams:
48
+ principal: float
49
+ annual_rate: float # nominal, in %
50
+ months: int
51
+ kind: str = CONSUMER
52
+ insurance_rate: float = 0.0 # annual, in % of the insured capital
53
+ insurance_on_balance: bool = False # False: on initial capital, True: on remaining capital
54
+ insurance_coverage: float = 100.0 # quotité, in % (200 for two borrowers fully covered)
55
+ deferral_type: str = DEFERRAL_NONE
56
+ deferral_months: int = 0
57
+ prepayment_month: int = 0
58
+ prepayment_amount: float = 0.0
59
+ prepayment_mode: str = REDUCE_DURATION
60
+
61
+
62
+ @dataclass
63
+ class Row:
64
+ month: int
65
+ payment: float # scheduled instalment (principal + interest), insurance excluded
66
+ interest: float
67
+ principal: float
68
+ insurance: float
69
+ prepayment: float
70
+ balance: float # remaining capital after this month
71
+
72
+ @property
73
+ def total(self) -> float:
74
+ return self.payment + self.insurance + self.prepayment
75
+
76
+
77
+ @dataclass
78
+ class Schedule:
79
+ principal: float
80
+ rows: list[Row]
81
+ prepayment_penalty: float = 0.0
82
+
83
+ @property
84
+ def months(self) -> int:
85
+ return len(self.rows)
86
+
87
+ @property
88
+ def total_interest(self) -> float:
89
+ # Computed from cash flows so capitalised interest (total deferral) is counted once.
90
+ return sum(r.payment + r.prepayment for r in self.rows) - self.principal
91
+
92
+ @property
93
+ def total_insurance(self) -> float:
94
+ return sum(r.insurance for r in self.rows)
95
+
96
+ @property
97
+ def total_paid(self) -> float:
98
+ return sum(r.total for r in self.rows) + self.prepayment_penalty
99
+
100
+ @property
101
+ def regular_payment(self) -> float:
102
+ for r in self.rows:
103
+ if r.principal > 0:
104
+ return r.payment
105
+ return 0.0
106
+
107
+
108
+ def annuity(principal: float, monthly_rate: float, months: int) -> float:
109
+ if months <= 0:
110
+ raise ValueError("duration must be positive")
111
+ if monthly_rate == 0:
112
+ return principal / months
113
+ return principal * monthly_rate / (1 - (1 + monthly_rate) ** -months)
114
+
115
+
116
+ def prepayment_penalty(kind: str, amount: float, balance_before: float, monthly_rate: float,
117
+ remaining_months: int, payment: float) -> float:
118
+ """Indemnité de remboursement anticipé (IRA)."""
119
+ if kind == MORTGAGE:
120
+ # min(6 months of interest on the amount repaid, 3 % of the remaining capital)
121
+ return min(6 * amount * monthly_rate, 0.03 * balance_before)
122
+ if kind == CONSUMER:
123
+ if amount <= CONSUMER_IRA_THRESHOLD:
124
+ return 0.0
125
+ pct = 0.01 if remaining_months > 12 else 0.005
126
+ # Capped at the interest that would still have been paid.
127
+ cap = max(payment * remaining_months - balance_before, 0.0)
128
+ return min(pct * amount, cap)
129
+ return 0.0
130
+
131
+
132
+ def amortize(p: LoanParams) -> Schedule:
133
+ if p.principal <= 0:
134
+ raise ValueError("loan amount must be positive")
135
+ if p.months <= 0:
136
+ raise ValueError("duration must be positive")
137
+ r = p.annual_rate / 100 / 12
138
+ insurance_rate = p.insurance_rate / 100 / 12 * p.insurance_coverage / 100
139
+ deferral = p.deferral_months if p.deferral_type != DEFERRAL_NONE else 0
140
+ if deferral >= p.months:
141
+ raise ValueError("the deferral must be shorter than the loan")
142
+
143
+ def insurance(balance: float) -> float:
144
+ return (balance if p.insurance_on_balance else p.principal) * insurance_rate
145
+
146
+ balance = p.principal
147
+ rows: list[Row] = []
148
+ for month in range(1, deferral + 1):
149
+ interest = balance * r
150
+ ins = insurance(balance)
151
+ if p.deferral_type == DEFERRAL_TOTAL:
152
+ balance += interest
153
+ rows.append(Row(month, 0.0, interest, 0.0, ins, 0.0, balance))
154
+ else:
155
+ rows.append(Row(month, interest, interest, 0.0, ins, 0.0, balance))
156
+
157
+ payment = annuity(balance, r, p.months - deferral)
158
+ penalty = 0.0
159
+ month = deferral
160
+ while balance > 0.005:
161
+ month += 1
162
+ ins = insurance(balance)
163
+ interest = balance * r
164
+ principal = payment - interest
165
+ if principal >= balance - 0.005 or month >= p.months:
166
+ principal = balance
167
+ balance -= principal
168
+ prepay = 0.0
169
+ if month == p.prepayment_month and p.prepayment_amount > 0 and balance > 0.005:
170
+ prepay = min(p.prepayment_amount, balance)
171
+ penalty = prepayment_penalty(p.kind, prepay, balance, r, p.months - month, payment)
172
+ balance -= prepay
173
+ if p.prepayment_mode == REDUCE_PAYMENT and balance > 0.005:
174
+ payment = annuity(balance, r, p.months - month)
175
+ rows.append(Row(month, principal + interest, interest, principal, ins, prepay, max(balance, 0.0)))
176
+ return Schedule(p.principal, rows, penalty)
177
+
178
+
179
+ def apr(net_amount: float, outflows: list[float]) -> float | None:
180
+ """Actuarial annual rate (TAEG), in %, for monthly outflows starting at month 1."""
181
+ if net_amount <= 0 or not outflows:
182
+ return None
183
+
184
+ def present_value(rate: float) -> float:
185
+ return sum(cf / (1 + rate) ** k for k, cf in enumerate(outflows, 1))
186
+
187
+ if present_value(0.0) <= net_amount:
188
+ return 0.0
189
+ lo, hi = 0.0, 1.0
190
+ for _ in range(100):
191
+ mid = (lo + hi) / 2
192
+ if present_value(mid) > net_amount:
193
+ lo = mid
194
+ else:
195
+ hi = mid
196
+ return ((1 + lo) ** 12 - 1) * 100
197
+
198
+
199
+ def usury_rate(kind: str, months: int, amount: float) -> float | None:
200
+ if kind == MORTGAGE:
201
+ for limit, rate in USURY_MORTGAGE:
202
+ if limit is None or months < limit:
203
+ return rate
204
+ if kind == CONSUMER:
205
+ for limit, rate in USURY_CONSUMER:
206
+ if limit is None or amount <= limit:
207
+ return rate
208
+ return None
209
+
210
+
211
+ def combine(schedules: list[Schedule]) -> list[Row]:
212
+ by_month: dict[int, Row] = {}
213
+ for s in schedules:
214
+ for r in s.rows:
215
+ acc = by_month.setdefault(r.month, Row(r.month, 0, 0, 0, 0, 0, 0))
216
+ acc.payment += r.payment
217
+ acc.interest += r.interest
218
+ acc.principal += r.principal
219
+ acc.insurance += r.insurance
220
+ acc.prepayment += r.prepayment
221
+ # A loan that has ended still contributes 0 to the balance; recompute it month by month.
222
+ for m, acc in by_month.items():
223
+ acc.balance = sum(_balance_at(s, m) for s in schedules)
224
+ return [by_month[m] for m in sorted(by_month)]
225
+
226
+
227
+ def _balance_at(s: Schedule, month: int) -> float:
228
+ if month <= s.months:
229
+ return s.rows[month - 1].balance
230
+ return 0.0
231
+
232
+
233
+ def yearly(rows: list[Row]) -> list[Row]:
234
+ years: dict[int, Row] = {}
235
+ for r in rows:
236
+ y = (r.month - 1) // 12 + 1
237
+ acc = years.setdefault(y, Row(y, 0, 0, 0, 0, 0, 0))
238
+ acc.payment += r.payment
239
+ acc.interest += r.interest
240
+ acc.principal += r.principal
241
+ acc.insurance += r.insurance
242
+ acc.prepayment += r.prepayment
243
+ acc.balance = r.balance
244
+ return [years[y] for y in sorted(years)]
245
+
246
+
247
+ @dataclass
248
+ class Phase:
249
+ start: int
250
+ end: int
251
+ payment: float # principal + interest
252
+ avg_insurance: float
253
+
254
+ @property
255
+ def total(self) -> float:
256
+ return self.payment + self.avg_insurance
257
+
258
+
259
+ def phases(rows: list[Row], tolerance: float = 0.5) -> list[Phase]:
260
+ """Group consecutive months that share the same instalment."""
261
+ result: list[Phase] = []
262
+ ins_sum = 0.0
263
+ for r in rows:
264
+ last = result[-1] if result else None
265
+ if last and abs(r.payment - last.payment) <= tolerance:
266
+ last.end = r.month
267
+ ins_sum += r.insurance
268
+ last.avg_insurance = ins_sum / (last.end - last.start + 1)
269
+ else:
270
+ result.append(Phase(r.month, r.month, r.payment, r.insurance))
271
+ ins_sum = r.insurance
272
+ return result
273
+
274
+
275
+ @dataclass
276
+ class Project:
277
+ works_cost: float
278
+ main: LoanParams # principal is computed by simulate()
279
+ down_payment: float = 0.0
280
+ grants: float = 0.0 # MaPrimeRénov', CEE, local aid...
281
+ eco_ptz: LoanParams | None = None
282
+ eco_ptz_cap: float | None = None
283
+ eco_ptz_max_months: int | None = None
284
+ application_fee: float = 0.0 # frais de dossier
285
+ guarantee_fee: float = 0.0 # caution / hypothèque
286
+ broker_fee: float = 0.0 # frais de courtage
287
+ monthly_income: float = 0.0
288
+ existing_debt: float = 0.0 # other monthly loan payments
289
+ usury_rate: float | None = None # None: use the built-in table
290
+
291
+
292
+ @dataclass
293
+ class Simulation:
294
+ amount_to_finance: float
295
+ main: Schedule | None = None
296
+ main_baseline: Schedule | None = None # main loan without early repayment
297
+ eco: Schedule | None = None
298
+ combined: list[Row] = field(default_factory=list)
299
+ fees: float = 0.0
300
+ apr: float | None = None
301
+ usury_rate: float | None = None
302
+ max_monthly: float = 0.0
303
+ debt_ratio: float | None = None
304
+ remaining_income: float | None = None # reste à vivre
305
+ warnings: list[str] = field(default_factory=list)
306
+
307
+ @property
308
+ def schedules(self) -> list[Schedule]:
309
+ return [s for s in (self.main, self.eco) if s]
310
+
311
+ @property
312
+ def total_interest(self) -> float:
313
+ return sum(s.total_interest for s in self.schedules)
314
+
315
+ @property
316
+ def total_insurance(self) -> float:
317
+ return sum(s.total_insurance for s in self.schedules)
318
+
319
+ @property
320
+ def total_penalty(self) -> float:
321
+ return sum(s.prepayment_penalty for s in self.schedules)
322
+
323
+ @property
324
+ def total_cost(self) -> float:
325
+ return self.total_interest + self.total_insurance + self.fees + self.total_penalty
326
+
327
+ @property
328
+ def total_borrowed(self) -> float:
329
+ return sum(s.principal for s in self.schedules)
330
+
331
+
332
+ def simulate(project: Project) -> Simulation:
333
+ to_finance = project.works_cost - project.down_payment - project.grants
334
+ sim = Simulation(amount_to_finance=to_finance)
335
+ if to_finance <= 0:
336
+ sim.warnings.append("Nothing to borrow: the down payment and grants cover the works.")
337
+ return sim
338
+
339
+ eco_amount = 0.0
340
+ if project.eco_ptz and project.eco_ptz.principal > 0:
341
+ eco = replace(project.eco_ptz, annual_rate=0.0, kind=ECO_PTZ)
342
+ if eco.principal > to_finance:
343
+ sim.warnings.append("The éco-PTZ exceeds the amount to finance; it has been reduced.")
344
+ eco = replace(eco, principal=to_finance)
345
+ if project.eco_ptz_cap is not None and eco.principal > project.eco_ptz_cap:
346
+ sim.warnings.append(f"The éco-PTZ for this category is capped at {project.eco_ptz_cap:,.0f} €.")
347
+ if project.eco_ptz_max_months is not None and eco.months > project.eco_ptz_max_months:
348
+ sim.warnings.append(
349
+ f"The éco-PTZ for this category cannot exceed {project.eco_ptz_max_months // 12} years.")
350
+ sim.eco = amortize(eco)
351
+ eco_amount = eco.principal
352
+
353
+ main_amount = to_finance - eco_amount
354
+ if main_amount > 0.005:
355
+ main = replace(project.main, principal=main_amount)
356
+ deferral = main.deferral_months if main.deferral_type != DEFERRAL_NONE else 0
357
+ if main.prepayment_amount > 0 and not deferral < main.prepayment_month <= main.months:
358
+ sim.warnings.append("Early repayment ignored: its month must fall after the deferral "
359
+ "and within the loan duration.")
360
+ main = replace(main, prepayment_amount=0.0)
361
+ sim.main = amortize(main)
362
+ sim.main_baseline = amortize(replace(main, prepayment_amount=0.0)) \
363
+ if main.prepayment_amount > 0 else sim.main
364
+
365
+ sim.fees = project.application_fee + project.guarantee_fee + project.broker_fee
366
+ sim.apr = apr(main_amount - sim.fees, [r.payment + r.insurance for r in sim.main_baseline.rows])
367
+ sim.usury_rate = (project.usury_rate if project.usury_rate is not None
368
+ else usury_rate(main.kind, main.months, main_amount))
369
+ if sim.apr is not None and sim.usury_rate is not None and sim.apr > sim.usury_rate:
370
+ sim.warnings.append(
371
+ f"APR {sim.apr:.2f} % exceeds the usury rate {sim.usury_rate:.2f} %: "
372
+ "a bank cannot legally grant this loan.")
373
+ if main.kind == CONSUMER and main_amount > CONSUMER_LOAN_CEILING:
374
+ sim.warnings.append("Above 75 000 €, a renovation loan falls under real-estate credit rules: "
375
+ "choose the real-estate loan type.")
376
+ if main.kind == MORTGAGE and main.months > MAX_MORTGAGE_MONTHS:
377
+ sim.warnings.append("The HCSF limits real-estate loans to 25 years.")
378
+
379
+ sim.combined = combine(sim.schedules)
380
+ sim.max_monthly = max((r.payment + r.insurance for r in sim.combined), default=0.0)
381
+ if project.monthly_income > 0:
382
+ sim.debt_ratio = (sim.max_monthly + project.existing_debt) / project.monthly_income * 100
383
+ sim.remaining_income = project.monthly_income - project.existing_debt - sim.max_monthly
384
+ if sim.debt_ratio > MAX_DEBT_RATIO:
385
+ sim.warnings.append(f"Debt ratio {sim.debt_ratio:.1f} % is above the {MAX_DEBT_RATIO:.0f} % "
386
+ "HCSF limit: banks will likely refuse.")
387
+ return sim
@@ -0,0 +1 @@
1
+ """Qt (PySide6) user interface."""