273 lines
9.7 KiB
Python
273 lines
9.7 KiB
Python
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, summary=summary)
|