first commit
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user