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 +3 -0
- loan_simu/__main__.py +3 -0
- loan_simu/core.py +387 -0
- loan_simu/ui/__init__.py +1 -0
- loan_simu/ui/advanced.py +487 -0
- loan_simu/ui/app.py +71 -0
- loan_simu/ui/charts.py +106 -0
- loan_simu/ui/simple.py +183 -0
- loan_simu/ui/theme.py +174 -0
- loan_simu/ui/widgets.py +283 -0
- loan_simu-0.2.0.dist-info/METADATA +145 -0
- loan_simu-0.2.0.dist-info/RECORD +16 -0
- loan_simu-0.2.0.dist-info/WHEEL +5 -0
- loan_simu-0.2.0.dist-info/entry_points.txt +2 -0
- loan_simu-0.2.0.dist-info/licenses/LICENSE +21 -0
- loan_simu-0.2.0.dist-info/top_level.txt +1 -0
loan_simu/__init__.py
ADDED
loan_simu/__main__.py
ADDED
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
|
loan_simu/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Qt (PySide6) user interface."""
|