from __future__ import annotations from datetime import date, timedelta from math import pow from .models import ( GraceType, HistoricalMonth, InstallmentType, OverpaymentEffect, ScheduleRow, SimulationRequest, SimulationResponse, Summary, ) def _round_money(value: float) -> float: return round(max(value, 0.0) + 1e-9, 2) def _add_months(base: date, months: int, day: int) -> date: month_index = base.month - 1 + months year = base.year + month_index // 12 month = month_index % 12 + 1 return date(year, month, min(day, 28)) def _easter(year: int) -> date: a = year % 19 b = year // 100 c = year % 100 d = b // 4 e = b % 4 f = (b + 8) // 25 g = (b - f + 1) // 3 h = (19 * a + b - d - g + 15) % 30 i = c // 4 k = c % 4 l = (32 + 2 * e + 2 * i - h - k) % 7 m = (a + 11 * h + 22 * l) // 451 month = (h + l - 7 * m + 114) // 31 day = ((h + l - 7 * m + 114) % 31) + 1 return date(year, month, day) def _polish_holidays(year: int) -> set[date]: easter = _easter(year) return { date(year, 1, 1), date(year, 1, 6), easter, easter + timedelta(days=1), date(year, 5, 1), date(year, 5, 3), easter + timedelta(days=60), date(year, 8, 15), date(year, 11, 1), date(year, 11, 11), date(year, 12, 25), date(year, 12, 26), } def _is_business_day(value: date) -> bool: return value.weekday() < 5 and value not in _polish_holidays(value.year) def _move_to_business_day(value: date, enabled: bool) -> date: if not enabled: return value while not _is_business_day(value): value += timedelta(days=1) return value def _due_date_for_month(req: SimulationRequest, month: int) -> date: raw = _add_months(req.loan_start_date, month, req.due_day) return _move_to_business_day(raw, req.move_due_date_to_business_day) def _historical_map(req: SimulationRequest) -> dict[int, HistoricalMonth]: return {item.month: item for item in req.historical_months} def _rate_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth] | None = None) -> float: historical = historical or _historical_map(req) hist = historical.get(month) if hist and hist.annual_rate is not None: return hist.annual_rate 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 _grace_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth]) -> GraceType: hist = historical.get(month) return hist.grace_type if hist else GraceType.none 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 _scheduled_overpayment_for_month(req: SimulationRequest, month: int) -> tuple[float, float]: total = 0.0 fee = 0.0 for op in req.overpayments: until = op.until_month or month active = False if op.repeat == "once" and month == op.month: active = True elif op.repeat == "monthly" and month >= op.month and month <= until: active = True elif op.repeat == "yearly" and month >= op.month and month <= until and (month - op.month) % 12 == 0: active = True if active: total += op.amount if op.commission_until_month is None or month <= op.commission_until_month: fee += op.amount * op.commission_percent / 100 return total, fee def _historical_overpayment_for_month(historical: dict[int, HistoricalMonth], month: int) -> tuple[float, float]: hist = historical.get(month) if not hist or hist.overpayment <= 0: return 0.0, 0.0 return hist.overpayment, hist.overpayment * hist.overpayment_commission_percent / 100 def _overpayment_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth]) -> tuple[float, float]: scheduled_amount, scheduled_fee = _scheduled_overpayment_for_month(req, month) hist_amount, hist_fee = _historical_overpayment_for_month(historical, month) return scheduled_amount + hist_amount, scheduled_fee + hist_fee def _term_months(req: SimulationRequest) -> int: return int(req.term_months or req.years * 12) def _simulate_raw(req: SimulationRequest, include_overpayments: bool = True) -> list[ScheduleRow]: balance = float(req.principal) total_months = _term_months(req) rows: list[ScheduleRow] = [] fixed_payment: float | None = None month = 1 previous_due = req.loan_start_date historical = _historical_map(req) previous_rate: float | None = None cumulative_interest = 0.0 cumulative_cost = 0.0 cumulative_overpayment = 0.0 while balance > 0.005 and month <= total_months + 600: months_left = max(total_months - month + 1, 1) due_date = _due_date_for_month(req, month) days = max((due_date - previous_due).days, 1) annual_rate = _rate_for_month(req, month, historical) daily_rate = annual_rate / 100 / 365 formula_monthly_rate = annual_rate / 100 / 12 interest = balance * daily_rate * days grace_type = _grace_for_month(req, month, historical) if req.installment_type == InstallmentType.equal: needs_recalc = fixed_payment is None or annual_rate != previous_rate if include_overpayments and req.overpayment_effect == OverpaymentEffect.lower_payment: needs_recalc = True if needs_recalc: fixed_payment = _monthly_payment(balance, formula_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: principal_base = balance / months_left payment = principal_base + interest principal_part = principal_base if grace_type == GraceType.interest_only: principal_part = 0.0 payment = interest elif grace_type == GraceType.full: principal_part = 0.0 payment = 0.0 interest = 0.0 if principal_part > balance: principal_part = balance payment = interest + principal_part balance -= principal_part overpayment = 0.0 overpayment_fee = 0.0 if include_overpayments: requested_overpayment, requested_fee = _overpayment_for_month(req, month, historical) overpayment = min(requested_overpayment, balance) overpayment_fee = requested_fee * (overpayment / requested_overpayment) if requested_overpayment > 0 else 0.0 balance -= overpayment cumulative_interest += interest cumulative_overpayment += overpayment cumulative_cost += interest + overpayment_fee rows.append(ScheduleRow( month=month, due_date=due_date.isoformat(), days=days, rate=round(annual_rate, 4), payment=_round_money(payment), principal_part=_round_money(principal_part), interest_part=_round_money(interest), overpayment=_round_money(overpayment), overpayment_fee=_round_money(overpayment_fee), remaining=_round_money(balance), grace_type=grace_type, cumulative_interest=_round_money(cumulative_interest), cumulative_cost=_round_money(cumulative_cost), cumulative_overpayment=_round_money(cumulative_overpayment), )) if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.lower_payment: fixed_payment = None previous_due = due_date previous_rate = annual_rate month += 1 return rows def simulate(req: SimulationRequest) -> SimulationResponse: actual = _simulate_raw(req, include_overpayments=True) baseline_req = req.model_copy(update={"overpayments": [], "historical_months": [ h.model_copy(update={"overpayment": 0.0, "overpayment_commission_percent": 0.0}) for h in req.historical_months ]}) 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_overpayment_fees = sum(r.overpayment_fee for r in actual) total_paid = sum(r.payment + r.overpayment + r.overpayment_fee 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), total_overpayment_fees=_round_money(total_overpayment_fees), interest_saved=_round_money(baseline_interest - total_interest - total_overpayment_fees), 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, payoff_date=actual[-1].due_date if actual else None, ) return SimulationResponse(schedule=actual, baseline_schedule=baseline, summary=summary)