odex30_standard/dev_odex30_accounting/account_loans/lib/pyloan.py

584 lines
26 KiB
Python

# Disable Ruff as we simply import the code from pyloan without any modifications
# ruff: noqa
import datetime as dt
import calendar as cal
import collections
from decimal import Decimal
from dateutil.relativedelta import relativedelta
Payment = collections.namedtuple('Payment',
['date', 'payment_amount', 'interest_amount', 'principal_amount',
'special_principal_amount', 'total_principal_amount',
'loan_balance_amount'])
Special_Payment = collections.namedtuple('Special_Payment', ['payment_amount', 'first_payment_date',
'special_payment_term',
'annual_payments'])
Loan_Summary = collections.namedtuple('Loan_Summary', ['loan_amount', 'total_payment_amount',
'total_principal_amount',
'total_interest_amount',
'residual_loan_balance',
'repayment_to_principal'])
class Loan(object):
def __init__(self, loan_amount, interest_rate, loan_term, start_date, payment_amount=None,
first_payment_date=None, payment_end_of_month=True, annual_payments=12,
interest_only_period=0, compounding_method='30E/360', loan_type='annuity'):
'''
Input validtion for attribute loan_amount
'''
try:
if isinstance(loan_amount, int) or isinstance(loan_amount, float):
if loan_amount < 0:
raise ValueError('Variable LOAN_AMMOUNT can only be non-negative.')
else:
raise TypeError(
'Variable LOAN_AMOUNT can only be of type integer or float, both non-negative.')
# handle exceptions for loan_amount
except ValueError as val_e:
print(val_e)
except TypeError as typ_e:
print(typ_e)
else:
self.loan_amount = Decimal(str(loan_amount))
'''
Input validation for attribute interet_rate
'''
try:
if isinstance(interest_rate, int) or isinstance(interest_rate, float):
if interest_rate < 0:
raise ValueError('Variable INTEREST_RATE can only be non-negative.')
else:
raise TypeError(
'Variable INTEREST_RATE can only be of type integer or float, both non-negative.')
except ValueError as val_e:
print(val_e)
except TypeError as typ_e:
print(typ_e)
else:
self.interest_rate = Decimal(str(interest_rate / 100)).quantize(Decimal(str(0.0001)))
'''
Input validation for attribute loan_term
'''
try:
if isinstance(loan_term, int):
if loan_term < 1:
raise ValueError(
'Variable LOAN_TERM can only be integers greater or equal to 1.')
else:
raise TypeError('Variable LOAN_TERM can only be of type integer.')
except ValueError as val_e:
print(val_e)
except TypeError as typ_e:
print(typ_e)
else:
self.loan_term = loan_term
'''
Input validation for attribute payment_amount
'''
try:
if payment_amount is None:
pass
elif payment_amount is not None and (
isinstance(payment_amount, int) or isinstance(payment_amount, float)):
if payment_amount < 0:
raise ValueError('Variable PAYMENT_AMOUNT can only be non-negative.')
else:
raise TypeError(
'Variable PAYMENT_AMOUNT can only be of type integer or float, both non-negative.')
except ValueError as val_e:
print(val_e)
except TypeError as typ_e:
print(typ_e)
else:
self.payment_amount = payment_amount
'''
Input validation for attribute start_date
'''
try:
if start_date is None:
raise TypeError('Varable START_DATE must by of type date with format YYYY-MM-DD')
elif bool(dt.datetime.strptime(start_date, '%Y-%m-%d')) is False:
raise ValueError
except ValueError as val_e:
print(val_e)
except TypeError as typ_e:
print(typ_e)
else:
self.start_date = dt.datetime.strptime(start_date, '%Y-%m-%d')
'''
Input validation for attribute first_paymnt_date
'''
try:
if first_payment_date is None:
pass
elif bool(dt.datetime.strptime(first_payment_date, '%Y-%m-%d')) is False:
raise ValueError
except ValueError as val_e:
print(val_e)
except TypeError as typ_e:
print(typ_e)
else:
self.first_payment_date = dt.datetime.strptime(first_payment_date,
'%Y-%m-%d') if first_payment_date is not None else None
try:
if self.first_payment_date is None:
pass
elif self.start_date > self.first_payment_date:
raise ValueError('FIRST_PAYMENT_DATE cannot be before START_DATE')
except ValueError as val_e:
print(val_e)
'''
Input validation for attribute payment_end_of_month
'''
try:
if not isinstance(payment_end_of_month, bool):
raise TypeError(
'Variable PAYMENT_END_OF_MONTH can only be of type boolean (either True or False)')
except TypeError as typ_e:
print(typ_e)
else:
self.payment_end_of_month = payment_end_of_month
'''
Input validation for attribute annual_payments
'''
try:
if isinstance(annual_payments, int):
if annual_payments not in [12, 4, 2, 1]:
raise ValueError(
'Attribute ANNUAL_PAYMENTS must be either set to 12, 4, 2 or 1.')
else:
raise TypeError('Attribute ANNUAL_PAYMENTS must be of type integer.')
except ValueError as val_e:
print(val_e)
except TypeError as typ_e:
print(typ_e)
else:
self.annual_payments = annual_payments
'''
Setting of no_of_payments and delta_dt if loan_term and annual_payments are set.
'''
try:
if hasattr(self, 'loan_term') is False or hasattr(self, 'annual_payments') is False:
print(self.loan_term)
print(self.annual_payments)
raise ValueError(
'Please make sure that LOAN_TERM and/or ANNUAL_PAYMENTS were correctly defined.11')
except ValueError as val_e:
print(val_e)
else:
self.no_of_payments = self.loan_term * self.annual_payments
self.delta_dt = Decimal(str(12 / self.annual_payments))
'''
Input validation for attribute interest_only_period
'''
try:
if isinstance(interest_only_period, int):
if interest_only_period < 0:
raise ValueError(
'Attribute INTEREST_ONLY_PERIOD must be greater or equal to 0.')
elif hasattr(self, 'no_of_payments') is False:
raise ValueError(
'Please make sure that LOAN_TERM and/or ANNUAL_PAYMENTS were correctly defined.')
elif hasattr(self,
'no_of_payments') is True and self.no_of_payments - interest_only_period < 0:
raise ValueError(
'Attribute INTEREST_ONLY_PERIOD is greater than product of LOAN_TERM and ANNUAL_PAYMENTS.')
else:
raise TypeError('Attribute INTEREST_ONLY_PERIOD must be of type integer.')
except ValueError as val_e:
print(val_e)
except TypeError as typ_e:
print(typ_e)
else:
self.interest_only_period = interest_only_period
'''
Input validation for attribute compounding_method
'''
try:
if isinstance(compounding_method, str):
if compounding_method not in ['30A/360', '30U/360', '30E/360', '30E/360 ISDA',
'A/360', 'A/365F', 'A/A ISDA', 'A/A AFB']:
raise ValueError(
'Attribute COMPOUNDING_METHOD must be set to one of the following: 30A/360, 30U/360, 30E/360, 30E/360 ISDA, A/360, A/365F, A/A ISDA, A/A AFB.')
else:
raise TypeError('Attribute COMPOUNDING_METHOD must be of type string')
except ValueError as val_e:
print(val_e)
except TypeError as typ_e:
print(typ_e)
else:
self.compounding_method = compounding_method
'''
Input validation for attribute loan_type
'''
try:
if isinstance(loan_type, str):
if loan_type not in ['annuity', 'linear', 'interest-only']:
raise ValueError(
'Attribute LOAN_TYPE must be either set to annuity or linear or interest-only.')
else:
raise TypeError('Attribute LOAN_TYPE must be of type string')
except ValueError as val_e:
print(val_e)
except TypeError as typ_e:
print(typ_e)
else:
self.loan_type = loan_type
# define non-input variables
self.special_payments = []
self.special_payments_schedule = []
@staticmethod
def _quantize(amount):
return Decimal(str(amount)).quantize(Decimal(str(0.01)))
@staticmethod
def _get_day_count(dt1, dt2, method, eom=False):
def get_julian_day_number(y, m, d):
julian_day_count = (1461 * (y + 4800 + (m - 14) / 12)) / 4 + (
367 * (m - 2 - 12 * ((m - 14) / 12))) / 12 - (
3 * ((y + 4900 + (m - 14) / 12) / 100)) / 4 + d - 32075
return julian_day_count
y1, m1, d1 = dt1.year, dt1.month, dt1.day
y2, m2, d2 = dt2.year, dt2.month, dt2.day
dt1_eom_day = cal.monthrange(y1, m1)[1]
dt2_eom_day = cal.monthrange(y2, m2)[1]
if method in {'30A/360', '30U/360', '30E/360', '30E/360 ISDA'}:
if method == '30A/360':
d1 = min(d1, 30)
d2 = min(d2, 30) if d1 == 30 else d2
if method == '30U/360':
if eom and m1 == 2 and d1 == dt1_eom_day and m2 == 2 and d2 == dt2_eom_day:
d2 = 30
if eom and m1 == 2 and d1 == dt1_eom_day:
d1 = 30
if d2 == 31 and d1 >= 30:
d2 = 30
if d1 == 31:
d1 = 30
if method == '30E/360':
if d1 == 31:
d1 = 30
if d2 == 31:
d2 = 30
if method == '30E/360 ISDA':
if d1 == dt1_eom_day:
d1 = 30
if d2 == dt2_eom_day and m2 != 2:
d2 = 30
day_count = (360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1))
year_days = 360
if method == 'A/365F':
day_count = (dt2 - dt1).days
year_days = 365
if method == 'A/360':
day_count = (dt2 - dt1).days
year_days = 360
if method in {'A/A ISDA', 'A/A AFB'}:
djn_dt1 = get_julian_day_number(y1, m1, d1)
djn_dt2 = get_julian_day_number(y2, m2, d2)
if y1 == y2:
day_count = djn_dt2 - djn_dt1
if method == 'A/A ISDA':
year_days = 366 if cal.isleap(y2) else 365
if method == 'A/A AFB':
year_days = 366 if cal.isleap(y1) and (m1 < 3) else 365
if y1 < y2:
djn_dt1_eoy = get_julian_day_number(y1, 12, 31)
day_count_dt1 = djn_dt1_eoy - djn_dt1
if method == 'A/A ISDA':
year_days_dt1 = 366 if cal.isleap(y1) else 365
if method == 'A/A AFB':
year_days_dt1 = 366 if cal.isleap(y1) and (m1 < 3) else 365
djn_dt2_boy = get_julian_day_number(y2, 1, 1)
day_count_dt2 = djn_dt2 - djn_dt2_boy
if method == 'A/A ISDA':
year_days_dt2 = 366 if cal.isleap(y2) else 365
if method == 'A/A AFB':
year_days_dt2 = 366 if cal.isleap(y2) and (m2 >= 3) else 365
diff = y2 - y1 - 1
day_count = (day_count_dt1 * year_days_dt2) + (day_count_dt2 * year_days_dt1) + (
diff * year_days_dt1 * year_days_dt2)
year_days = year_days_dt1 * year_days_dt2
factor = day_count / year_days
return factor
@staticmethod
def _get_special_payment_schedule(self, special_payment):
no_of_payments = special_payment.special_payment_term * special_payment.annual_payments
annual_payments = special_payment.annual_payments
dt0 = dt.datetime.strptime(special_payment.first_payment_date, '%Y-%m-%d')
special_payment_amount = self._quantize(special_payment.payment_amount)
initial_special_payment = Payment(date=dt0, payment_amount=self._quantize(0),
interest_amount=self._quantize(0),
principal_amount=self._quantize(0),
special_principal_amount=special_payment_amount,
total_principal_amount=self._quantize(0),
loan_balance_amount=self._quantize(0))
special_payment_schedule = [initial_special_payment]
for i in range(1, no_of_payments):
date = dt0 + relativedelta(months=i * 12 / annual_payments)
special_payment = Payment(date=date, payment_amount=self._quantize(0),
interest_amount=self._quantize(0),
principal_amount=self._quantize(0),
special_principal_amount=special_payment_amount,
total_principal_amount=self._quantize(0),
loan_balance_amount=self._quantize(0))
special_payment_schedule.append(special_payment)
return special_payment_schedule
'''
Define method that calculates payment schedule
'''
def get_payment_schedule(self):
attributes = ['loan_amount', 'interest_rate', 'loan_term', 'payment_amount', 'start_date',
'first_payment_date', 'payment_end_of_month', 'annual_payments',
'no_of_payments', 'delta_dt', 'interest_only_period', 'compounding_method',
'special_payments', 'special_payments_schedule']
raise_error_flag = 0
for attribute in attributes:
if hasattr(self, attribute) is False:
raise_error_flag = raise_error_flag + 1
try:
if raise_error_flag != 0:
raise ValueError(
'Necessary attributes are not well defined, please review your inputs')
except ValueError as val_e:
print(val_e)
else:
initial_payment = Payment(date=self.start_date, payment_amount=self._quantize(0),
interest_amount=self._quantize(0),
principal_amount=self._quantize(0),
special_principal_amount=self._quantize(0),
total_principal_amount=self._quantize(0),
loan_balance_amount=self._quantize(self.loan_amount))
payment_schedule = [initial_payment]
interest_only_period = self.interest_only_period
# take care of loan type
if self.loan_type == 'annuity':
if self.payment_amount is None:
regular_principal_payment_amount = self.loan_amount * (
(self.interest_rate / self.annual_payments) * (
1 + (self.interest_rate / self.annual_payments)) ** (
(self.no_of_payments - interest_only_period))) / ((1 + (
self.interest_rate / self.annual_payments)) ** ((
self.no_of_payments - interest_only_period)) - 1)
else:
regular_principal_payment_amount = self.payment_amount
if self.loan_type == 'linear':
if self.payment_amount is None:
regular_principal_payment_amount = self.loan_amount / (
self.no_of_payments - self.interest_only_period)
else:
regular_principal_payment_amount = self.payment_amount
if self.loan_type == 'interest-only':
regular_principal_payment_amount = 0
interest_only_period = self.no_of_payments
if self.first_payment_date is None:
if self.payment_end_of_month == True:
if self.start_date.day == \
cal.monthrange(self.start_date.year, self.start_date.month)[1]:
dt0 = self.start_date
else:
dt0 = dt.datetime(self.start_date.year, self.start_date.month,
cal.monthrange(self.start_date.year,
self.start_date.month)[1], 0,
0) + relativedelta(months=-12 / self.annual_payments)
else:
dt0 = self.start_date
else:
dt0 = max(self.first_payment_date, self.start_date) + relativedelta(
months=-12 / self.annual_payments)
# take care of special payments
special_payments_schedule_raw = []
special_payments_schedule = []
special_payments_dates = []
if len(self.special_payments_schedule) > 0:
for i in range(len(self.special_payments_schedule)):
for j in range(len(self.special_payments_schedule[i])):
special_payments_schedule_raw.append(
[self.special_payments_schedule[i][j].date,
self.special_payments_schedule[i][j].special_principal_amount])
if self.special_payments_schedule[i][j].date not in special_payments_dates:
special_payments_dates.append(self.special_payments_schedule[i][j].date)
for i in range(len(special_payments_dates)):
amt = self._quantize(str(0))
for j in range(len(special_payments_schedule_raw)):
if special_payments_schedule_raw[j][0] == special_payments_dates[i]:
amt += special_payments_schedule_raw[j][1]
special_payments_schedule.append([special_payments_dates[i], amt])
# calculate payment schedule
m = 0
for i in range(1, self.no_of_payments + 1):
date = dt0 + relativedelta(months=i * 12 / self.annual_payments)
if self.payment_end_of_month == True and self.first_payment_date is None:
eom_day = cal.monthrange(date.year, date.month)[1]
date = date.replace(day=eom_day) # dt.datetime(date.year,date.month,eom_day)
special_principal_amount = self._quantize(0)
bop_date = payment_schedule[(i + m) - 1].date
compounding_factor = Decimal(
str(self._get_day_count(bop_date, date, self.compounding_method,
eom=self.payment_end_of_month)))
balance_bop = self._quantize(payment_schedule[(i + m) - 1].loan_balance_amount)
for j in range(len(special_payments_schedule)):
if date == special_payments_schedule[j][0]:
special_principal_amount = special_payments_schedule[j][1]
if (bop_date < special_payments_schedule[j][0] and special_payments_schedule[j][
0] < date):
# handle special payment inserts
compounding_factor = Decimal(
str(self._get_day_count(bop_date, special_payments_schedule[j][0],
self.compounding_method,
eom=self.payment_end_of_month)))
interest_amount = self._quantize(0) if balance_bop == Decimal(
str(0)) else self._quantize(
balance_bop * self.interest_rate * compounding_factor)
principal_amount = self._quantize(0)
special_principal_amount = self._quantize(0) if balance_bop == Decimal(
str(0)) else min(special_payments_schedule[j][1] - interest_amount,
balance_bop)
total_principal_amount = min(principal_amount + special_principal_amount,
balance_bop)
total_payment_amount = total_principal_amount + interest_amount
balance_eop = max(balance_bop - total_principal_amount, self._quantize(0))
payment = Payment(date=special_payments_schedule[j][0],
payment_amount=total_payment_amount,
interest_amount=interest_amount,
principal_amount=principal_amount,
special_principal_amount=special_principal_amount,
total_principal_amount=special_principal_amount,
loan_balance_amount=balance_eop)
payment_schedule.append(payment)
m += 1
# handle regular payment inserts : update bop_date and bop_date, and special_principal_amount
bop_date = special_payments_schedule[j][0]
balance_bop = balance_eop
special_principal_amount = self._quantize(0)
compounding_factor = Decimal(
str(self._get_day_count(bop_date, date, self.compounding_method,
eom=self.payment_end_of_month)))
interest_amount = self._quantize(0) if balance_bop == Decimal(
str(0)) else self._quantize(
balance_bop * self.interest_rate * compounding_factor)
principal_amount = self._quantize(0) if balance_bop == Decimal(
str(0)) or interest_only_period >= i else min(
self._quantize(regular_principal_payment_amount) - (
interest_amount if self.loan_type == 'annuity' else 0), balance_bop)
special_principal_amount = min(balance_bop - principal_amount,
special_principal_amount) if interest_only_period < i else self._quantize(
0)
total_principal_amount = min(principal_amount + special_principal_amount,
balance_bop)
total_payment_amount = total_principal_amount + interest_amount
balance_eop = max(balance_bop - total_principal_amount, self._quantize(0))
payment = Payment(date=date, payment_amount=total_payment_amount,
interest_amount=interest_amount,
principal_amount=principal_amount,
special_principal_amount=special_principal_amount,
total_principal_amount=total_principal_amount,
loan_balance_amount=balance_eop)
payment_schedule.append(payment)
return payment_schedule
def add_special_payment(self, payment_amount, first_payment_date, special_payment_term,
annual_payments):
special_payment = Special_Payment(payment_amount=payment_amount,
first_payment_date=first_payment_date,
special_payment_term=special_payment_term,
annual_payments=annual_payments)
self.special_payments.append(special_payment)
self.special_payments_schedule.append(
self._get_special_payment_schedule(self, special_payment))
def get_loan_summary(self):
payment_schedule = self.get_payment_schedule()
total_payment_amount = 0
total_interest_amount = 0
total_principal_amount = 0
repayment_to_principal = 0
for payment in payment_schedule:
total_payment_amount += payment.payment_amount
total_interest_amount += payment.interest_amount
total_principal_amount += payment.total_principal_amount
repayment_to_principal = self._quantize(total_payment_amount / total_principal_amount)
loan_summary = Loan_Summary(loan_amount=self._quantize(self.loan_amount),
total_payment_amount=total_payment_amount,
total_principal_amount=total_principal_amount,
total_interest_amount=total_interest_amount,
residual_loan_balance=self._quantize(
self.loan_amount - total_principal_amount),
repayment_to_principal=repayment_to_principal)
return loan_summary