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)