"""
PyMBS is a Python library for use in modeling Mortgage-Backed Securities.
Copyright (C) 2019 Brian Farrell
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Contact: brian.farrell@me.com
"""
from collections import namedtuple
import decimal
import json
import re
from pymbs.config import config
from pymbs.enums import URL
from pymbs.exceptions import handle_gracefully, PayRuleError
from pymbs.log import get_logger
logger = get_logger(__name__)
dec = decimal.Decimal
ACNT = namedtuple('AssumedRepline', [
'group_id',
'repline',
'upb',
'coupon',
'original_term',
'wac',
'wam',
'wala'
])
PNT = namedtuple('Pandas', [
'Index',
'period',
'payment_date',
'beginning_balance',
'smm',
'scheduled_payment',
'net_interest',
'scheduled_principal',
'prepayment',
'total_principal',
'cash_flow',
'ending_balance',
'buckets'
])
def _round_dec(decimal_value, precision=config.round_precision):
places = dec('10') ** dec(f"{-precision}")
try:
rounded_value = decimal_value.quantize(places)
except decimal.InvalidOperation:
ctx = decimal.getcontext()
dv_rep = decimal_value.as_tuple()
len_integer_part = len(dv_rep.digits) + dv_rep.exponent
max_allowed_precision = ctx.prec - len_integer_part
places = dec('10') ** dec(f"{-max_allowed_precision}")
rounded_value = decimal_value.quantize(places)
handle_gracefully(
config._ipython_active,
logger,
'rounding_error',
decimal_value=decimal_value,
precision=precision,
config_precision=config.precision,
max_allowed_precision=max_allowed_precision,
pymbs_config_url=URL.PYMBS_CONFIG.value,
quantize_help_url=URL.QUANTIZE_HELP.value,
no_exit=True
)
return rounded_value
def _parse_waterfall(waterfall):
wf = []
for rule in waterfall:
pr = re.match(
r'(?P<full_rule>(?P<pay_rule>\w+)\('
r'(?P<buckets>\[(?:\'\w+\'(?:,\s|\]))*)'
r'(?:,\s(?P<tranches>\[(?:\'\w+\'(?:,\s|\]))*))?(?:(?:,\s)'
r'(?:\'(?P<expression>.*)\'))?\))',
rule,
re.S
)
if not pr:
raise PayRuleError(rule, 'no_parse')
if pr.group('pay_rule') == 'pay_accrue':
if pr.group('tranches') or pr.group('expression'):
raise PayRuleError(pr.group('full_rule'), 'invalid')
else:
func = pr.group('full_rule')
# In the case of the pay_accrue rule, the tranches to accrue
# are captured by the 'buckets' group
tranches = pr.group('buckets')
elif pr.group('pay_rule') == 'calculate':
if pr.group('tranches'):
raise PayRuleError(pr.group('full_rule'), 'invalid')
else:
func = pr.group('full_rule')
tranches = None
else:
if pr.group('pay_rule') not in ['pay_accrue', 'calculate']:
if pr.group('expression') or not \
(pr.group('buckets') and pr.group('tranches')):
raise PayRuleError(pr.group('full_rule'), 'invalid')
else:
func = pr.group('full_rule')
tranches = pr.group('tranches')
wf.append(dict(tranches=tranches, func=func))
return wf
def _parse_expression(exp):
expr_tokens = re.match(
r'(?P<first_term>(?:COLL|(?:^COLL|[A-Za-z0-9]))+?_'
r'(?:PRINCIPAL|ACCRUAL|PREPAY|BALANCE|SCHEDULED|VAR))'
r'(?:\s(?P<operator>\+|\-|\*|\/)\s)'
r'(?=(?:(?:COLL|(?:^COLL|[A-Za-z0-9])+)_'
r'(?:PRINCIPAL|ACCRUAL|PREPAY|BALANCE|SCHEDULED|VAR)))'
r'(?<=(?:\s(?:\+|\-|\*|\/)\s))'
r'(?P<second_term>(?:COLL|(?:^COLL|[A-Za-z0-9]))+?_'
r'(?:PRINCIPAL|ACCRUAL|PREPAY|BALANCE|SCHEDULED|VAR))', exp, re.S)
first_term_tokens = re.findall(
r'(?P<token>[A-Za-z0-9]+)', expr_tokens.group('first_term'), re.S)
second_term_tokens = re.findall(
r'(?P<token>[A-Za-z0-9]+)', expr_tokens.group('second_term'), re.S)
# If the first token is 'COLL', then we assume that it is referring to the
# collater cash flow passed to the calling function
if first_term_tokens[0] == 'COLL':
ft_1 = 'payment'
# If the first token is NOT COLL and the second token is NOT VAR, then we
# assume that it is referreing to cash flow of a Tranche in the same
# group as the one for which the waterfall is being run
if first_term_tokens[0] != 'COLL' and first_term_tokens[1] != 'VAR':
ft_1 = (f"model['groups'][group_id]['tranches']"
f"['{first_term_tokens[0]}'].periodic_cf")
# If the second token is VAR, then the first token is the name of the
# variable being evaluated
if first_term_tokens[1] == 'VAR':
ft_1 = first_term_tokens[0]
ft_2 = ''
elif first_term_tokens[1] == 'PRINCIPAL':
ft_2 = 'total_principal'
elif first_term_tokens[1] == 'ACCRUAL':
ft_2 = 'accrual'
elif first_term_tokens[1] == 'PREPAY':
ft_2 = 'prepayment'
elif first_term_tokens[1] == 'BALANCE':
ft_2 = 'beginning_balance'
elif first_term_tokens[1] == 'SCHEDULED':
ft_2 = 'scheduled_principal'
# If the first token is 'COLL', then we assume that it is referring to the
# collater cash flow passed to the calling function
if second_term_tokens[0] == 'COLL':
st_1 = 'payment'
# If the first token is NOT COLL and the second token is NOT VAR, then we
# assume that it is referreing to cash flow of a Tranche in the same
# group as the one for which the waterfall is being run
if second_term_tokens[0] != 'COLL' and first_term_tokens[1] != 'VAR':
st_1 = (f"model['groups'][group_id]['tranches']"
f"['{second_term_tokens[0]}'].periodic_cf")
# If the second token is VAR, then the first token is the name of the
# variable being evaluated
if second_term_tokens[1] == 'VAR':
st_1 = second_term_tokens[0]
st_2 = ''
elif second_term_tokens[1] == 'PRINCIPAL':
st_2 = 'total_principal'
elif second_term_tokens[1] == 'ACCRUAL':
st_2 = 'accrual'
elif second_term_tokens[1] == 'PREPAY':
st_2 = 'prepayment'
elif second_term_tokens[1] == 'BALANCE':
st_2 = 'beginning_balance'
elif second_term_tokens[1] == 'SCHEDULED':
st_2 = 'scheduled_principal'
if ft_2 == '':
term_1 = f"{ft_1} "
else:
term_1 = f"{ft_1}.{ft_2}"
if st_2 == '':
term_2 = f"{st_1} "
else:
term_2 = f"{st_1}['{st_2}']"
operator = f" {expr_tokens.group('operator')} "
return f"{term_1}{operator}{term_2}"
[docs]class DecimalEncoder(json.JSONEncoder):
[docs] def default(self, o):
if isinstance(o, decimal.Decimal):
return str(o)
return super(DecimalEncoder, self).default(o)
[docs]class TSDecimalDecoder(json.JSONDecoder):
"""TSDecimalDecoder is a custom JSON decoder for the Terms Sheet
"""
def __init__(self):
json.JSONDecoder.__init__(self, object_hook=self.object_hook)
[docs] def object_hook(self, obj):
dict_keys = ['deal', 'pricing_speed', ]
list_dict_keys = [
'assumed',
'known',
'notional_with',
'securitized',
'schedules',
'tranches'
]
list_str_keys = ['range', 'values']
for k, v in obj.items():
if k in dict_keys:
item = self.decimalize_dict(obj[k])
obj[k].update(item)
elif k in list_dict_keys:
for idx, item in enumerate(obj[k]):
item = self.decimalize_dict(item)
obj[k][idx].update(item)
elif k in list_str_keys:
for idx, item in enumerate(obj[k]):
item = self.decimalize(item)
obj[k][idx] = item
return obj
[docs] def decimalize(self, value):
try:
value = decimal.Decimal(value)
except (decimal.InvalidOperation, TypeError):
pass
return value
[docs] def decimalize_dict(self, dct):
for k, v in dct.items():
if isinstance(dct[k], str):
try:
value = decimal.Decimal(dct[k])
except (decimal.InvalidOperation, TypeError):
pass
else:
dct[k] = value
return dct