Files
mortgage-simulator/app/simulator.py
T
Mateusz Gruszczyński 3ab205b769 first commit
2026-06-03 12:36:51 +02:00

130 lines
4.9 KiB
Python

from __future__ import annotations
from math import pow
from .models import SimulationRequest, ScheduleRow, Summary, SimulationResponse, InstallmentType, OverpaymentEffect
def _round_money(value: float) -> float:
return round(max(value, 0.0) + 1e-9, 2)
def _rate_for_month(req: SimulationRequest, month: int) -> float:
changes = sorted(req.rate_changes, key=lambda x: x.month)
rate = req.base_rate + req.margin
for change in changes:
if month >= change.month:
rate = change.annual_rate
else:
break
return rate
def _monthly_payment(balance: float, monthly_rate: float, months_left: int) -> float:
if months_left <= 0:
return balance
if monthly_rate == 0:
return balance / months_left
factor = pow(1 + monthly_rate, months_left)
return balance * monthly_rate * factor / (factor - 1)
def _overpayment_for_month(req: SimulationRequest, month: int) -> float:
total = 0.0
for op in req.overpayments:
until = op.until_month or month
if op.repeat == "once" and month == op.month:
total += op.amount
elif op.repeat == "monthly" and month >= op.month and month <= until:
total += op.amount
elif op.repeat == "yearly" and month >= op.month and month <= until and (month - op.month) % 12 == 0:
total += op.amount
return total
def _simulate_raw(req: SimulationRequest, include_overpayments: bool = True) -> list[ScheduleRow]:
balance = float(req.principal)
total_months = int(req.years * 12)
rows: list[ScheduleRow] = []
fixed_payment = None
recalculation_month = 1
month = 1
while balance > 0.005 and month <= total_months + 600:
months_left = max(total_months - month + 1, 1)
annual_rate = _rate_for_month(req, month)
monthly_rate = annual_rate / 100 / 12
interest = balance * monthly_rate
if req.installment_type == InstallmentType.equal:
if fixed_payment is None or req.overpayment_effect == OverpaymentEffect.lower_payment:
fixed_payment = _monthly_payment(balance, monthly_rate, months_left)
payment = fixed_payment
principal_part = max(payment - interest, 0.0)
else:
principal_base = req.principal / total_months
if req.overpayment_effect == OverpaymentEffect.lower_payment:
active_months_left = max(total_months - month + 1, 1)
principal_base = balance / active_months_left
payment = principal_base + interest
principal_part = principal_base
if principal_part > balance:
principal_part = balance
payment = interest + principal_part
balance -= principal_part
overpayment = _overpayment_for_month(req, month) if include_overpayments else 0.0
overpayment = min(overpayment, balance)
balance -= overpayment
rows.append(ScheduleRow(
month=month,
rate=round(annual_rate, 4),
payment=_round_money(payment),
principal_part=_round_money(principal_part),
interest_part=_round_money(interest),
overpayment=_round_money(overpayment),
remaining=_round_money(balance),
))
if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.lower_payment:
fixed_payment = None
recalculation_month = month + 1
if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.shorten:
# przy skroceniu okresu rata zostaje z grubsza taka sama; petla zakonczy sie szybciej
pass
month += 1
if month - recalculation_month > 1200:
break
return rows
def simulate(req: SimulationRequest) -> SimulationResponse:
actual = _simulate_raw(req, include_overpayments=True)
baseline_req = req.model_copy(update={"overpayments": []})
baseline = _simulate_raw(baseline_req, include_overpayments=False)
total_interest = sum(r.interest_part for r in actual)
total_overpayment = sum(r.overpayment for r in actual)
total_paid = sum(r.payment + r.overpayment for r in actual)
baseline_interest = sum(r.interest_part for r in baseline)
payments = [r.payment for r in actual]
summary = Summary(
months=len(actual),
total_paid=_round_money(total_paid),
total_interest=_round_money(total_interest),
total_overpayment=_round_money(total_overpayment),
interest_saved=_round_money(baseline_interest - total_interest),
months_saved=max(len(baseline) - len(actual), 0),
baseline_interest=_round_money(baseline_interest),
baseline_months=len(baseline),
average_payment=_round_money(sum(payments) / len(payments)) if payments else 0,
max_payment=_round_money(max(payments)) if payments else 0,
)
return SimulationResponse(schedule=actual, summary=summary)