PK! k\\payulator/__init__.pyfrom .constants import * from .helpers import * from .loan import * __version__ = "3.2.0" PK!Fmpayulator/constants.pyimport numpy as np #: Frequency string -> number of occurrences per year NUM_BY_FREQ = { 'annually': 1, 'semiannually': 2, 'triannually': 3, 'quarterly': 4, 'bimonthly': 6, 'monthly': 12, 'fortnightly': 26, 'weekly': 52, 'daily': 365, 'continuously': np.inf, } #: LOAN_ATTRS = [ 'code', 'kind', 'principal', 'interest_rate', 'payment_freq', 'compounding_freq', # optional 'num_payments', 'first_payment_date', 'fee', ] PK!P66payulator/helpers.pyimport math from copy import copy from typing import List, Union, Optional import numpy as np import pandas as pd from pandas import DataFrame from . import constants as cs def freq_to_num(freq: str, *, allow_cts: bool = False) -> Union[int, float]: """ Map frequency name to number of occurrences per year via :const:`NUM_BY_FREQ`. If not ``allow_cts``, then remove the ``"continuouly"`` option. Raise a ``ValueError`` in case of no frequency match. """ d = copy(cs.NUM_BY_FREQ) if not allow_cts: del d["continuously"] try: return d[freq] except KeyError: raise ValueError( f"Invalid frequency {freq}. " f"Frequency must be one of {d.keys()}" ) def to_date_offset(num_per_year: int) -> Union[pd.DateOffset, None]: """ Convert the given number of occurrences per year to its corresponding period (Pandas DateOffset object), e.g. map 4 to ``pd.DateOffset(months=3)``. Return ``None`` if num_per_year is not one of ``[1, 2, 3, 4, 6, 12, 26, 52, 365]``. """ k = num_per_year if k in [1, 2, 3, 4, 6, 12]: d = pd.DateOffset(months=12 // k) elif k == 26: d = pd.DateOffset(weeks=2) elif k == 52: d = pd.DateOffset(weeks=1) elif k == 365: d = pd.DateOffset(days=1) else: d = None return d def compute_period_interest_rate( interest_rate: float, compounding_freq: str, payment_freq: str ) -> float: """ Compute the interest rate per payment period given an annual interest rate, a compounding frequency, and a payment freq. See the function :func:`freq_to_num` for acceptable frequencies. """ i = interest_rate j = freq_to_num(compounding_freq, allow_cts=True) k = freq_to_num(payment_freq) if np.isinf(j): return math.exp(i / k) - 1 else: return (1 + i / j) ** (j / k) - 1 def build_principal_fn( principal: float, interest_rate: float, compounding_freq: str, payment_freq: str, num_payments: int, ): """ Compute the remaining loan principal, the loan balance, as a function of the number of payments made. Return the resulting function. """ P = principal I = compute_period_interest_rate(interest_rate, compounding_freq, payment_freq) n = num_payments def p(t): if I == 0: return P - t * P / n else: return P * (1 - ((1 + I) ** t - 1) / ((1 + I) ** n - 1)) return p def amortize( principal: float, interest_rate: float, compounding_freq: str, payment_freq: str, num_payments: str, ) -> float: """ Given the loan parameters - ``principal``: (float) amount of loan, that is, the principal - ``interest_rate``: (float) nominal annual interest rate (not as a percentage), e.g. 0.1 for 10% - ``compounding_freq``: (string) compounding frequency; one of the keys of :const:`NUM_BY_FREQ`, e.g. "monthly" - ``payment_freq``: (string) payments frequency; one of the keys of :const:`NUM_BY_FREQ`, e.g. "monthly" - ``num_payments``: (integer) number of payments in the loan term return the periodic payment amount due to amortize the loan into equal payments occurring at the frequency ``payment_freq``. See the function :func:`freq_to_num` for valid frequncies. Notes: - https://en.wikipedia.org/wiki/Amortization_calculator - https://www.vertex42.com/ExcelArticles/amortization-calculation.html """ P = principal I = compute_period_interest_rate(interest_rate, compounding_freq, payment_freq) n = num_payments if I == 0: A = P / n else: A = P * I / (1 - (1 + I) ** (-n)) return A def summarize_amortized_loan( principal: float, interest_rate: float, compounding_freq: str, payment_freq: str, num_payments: str, fee: float = 0, first_payment_date: Optional[str] = None, decimals: int = 2, ) -> DataFrame: """ Amortize a loan with the given parameters according to the function :func:`amortize`, and return a dictionary with the following keys and values: - ``"periodic_payment"``: periodic payment amount according to amortization - ``"payment_schedule"``: DataFrame; schedule of loan payments broken into principal payments and interest payments - ``"interest_total"``: total interest paid on loan - ``"interest_and_fee_total"``: interest_total plus loan fee - ``"payment_total"``: total of all loan payments, including the loan fee - ``"interest_and_fee_total/principal`` - ``"notes"``: NaN for future notes If a start date is given (YYYY-MM-DD string), then include payment dates in the payment schedule. Round all values to the given number of decimal places, but do not round if ``decimals is None``. The payment schedule has the columns: - ``"payment_sequence"``: integer - ``"payment_date"``: (optional) YYYY-MM-DD date string if ``first_payment_date`` is given - ``"beginning_balance"``: float; balance on the payment date before the principal payment is made - ``"principal_payment"``: float; principal payment made on payment date - ``"ending_balance"``: float; balance on the payment date after the principal payment is made - ``"interest_payment"``: float; interest payment made on payment date - ``"fee_payment"``: float; fee payment made on payment date; equals the fee on the first payment date and 0 elsewhere - ``"notes"``: NaN """ A = amortize(principal, interest_rate, compounding_freq, payment_freq, num_payments) p = build_principal_fn( principal, interest_rate, compounding_freq, payment_freq, num_payments ) n = num_payments f = ( pd.DataFrame({"payment_sequence": range(1, n + 1)}) .assign(beginning_balance=lambda x: (x.payment_sequence - 1).map(p)) .assign( principal_payment=lambda x: x.beginning_balance.diff(-1).fillna( x.beginning_balance.iat[-1] ) ) .assign(ending_balance=lambda x: x.beginning_balance - x.principal_payment) .assign(interest_payment=lambda x: A - x.principal_payment) .assign(fee_payment=0) .assign(notes=np.nan) ) f.fee_payment.iat[0] = fee date_offset = to_date_offset(freq_to_num(payment_freq)) if first_payment_date and date_offset: # Kludge for pd.date_range not working easily here; # see https://github.com/pandas-dev/pandas/issues/2289 f["payment_date"] = [ pd.Timestamp(first_payment_date) + j * date_offset for j in range(n) ] # Put payment date first cols = f.columns.tolist() cols.remove("payment_date") cols.insert(1, "payment_date") f = f[cols].copy() # Bundle result into dictionary d = {} d["periodic_payment"] = A d["payment_schedule"] = f d["interest_total"] = f["interest_payment"].sum() d["interest_and_fee_total"] = d["interest_total"] + fee d["payment_total"] = d["interest_and_fee_total"] + principal d["interest_and_fee_total/principal"] = d["interest_and_fee_total"] / principal if decimals is not None: for key, val in d.items(): if isinstance(val, pd.DataFrame): d[key] = val.round(decimals) else: d[key] = round(val, 2) return d def summarize_interest_only_loan( principal: float, interest_rate: float, payment_freq: str, num_payments: int, fee: float = 0, first_payment_date: Optional[str] = None, decimals: int = 2, ) -> DataFrame: """ Create a payment schedule etc. for an interest-only loan with the given parameters (see the doctstring of :func:`amortize`). Return a dictionary with the following keys and values. - ``"payment_schedule"``: DataFrame; schedule of loan payments broken into principal payments and interest payments - ``"interest_total"``: total interest paid on loan - ``"interest_and_fee_total"``: interest_total plus loan fee - ``"payment_total"``: total of all loan payments, including the loan fee - ``"interest_and_fee_total/principal``: interest_and_fee_total/principal - ``"notes"``: NaN for future notes If a start date is given (YYYY-MM-DD string), then include payment dates in the payment schedule. Round all values to the given number of decimal places, but do not round if ``decimals is None``. The payment schedule has the columns: - ``"payment_sequence"``: integer - ``"payment_date"``: (optional) YYYY-MM-DD date string if ``first_payment_date`` is given - ``"beginning_balance"``: float; balance on the payment date before the principal payment is made - ``"principal_payment"``: float; principal payment made on payment date - ``"ending_balance"``: float; balance on the payment date after the principal payment is made - ``"interest_payment"``: float; interest payment made on payment date - ``"fee_payment"``: float; fee payment made on payment date; equals the fee on the first payment date and 0 elsewhere - ``"notes"``: NaN """ k = freq_to_num(payment_freq) A = principal * interest_rate / k n = num_payments f = ( pd.DataFrame({"payment_sequence": range(1, n + 1)}) .assign(beginning_balance=principal) .assign(principal_payment=0) .assign(ending_balance=principal) .assign(interest_payment=A) .assign(fee_payment=0) .assign(notes=np.nan) ) f.principal_payment.iat[-1] = principal f.ending_balance.iat[-1] = 0 f.fee_payment.iat[0] = fee date_offset = to_date_offset(k) if first_payment_date and date_offset: # Kludge for pd.date_range not working easily here; # see https://github.com/pandas-dev/pandas/issues/2289 f["payment_date"] = [ pd.Timestamp(first_payment_date) + j * date_offset for j in range(n) ] # Put payment date first cols = f.columns.tolist() cols.remove("payment_date") cols.insert(1, "payment_date") f = f[cols].copy() # Bundle result into dictionary d = {} d["payment_schedule"] = f d["interest_total"] = f["interest_payment"].sum() d["interest_and_fee_total"] = d["interest_total"] + fee d["payment_total"] = d["interest_and_fee_total"] + principal d["interest_and_fee_total/principal"] = d["interest_and_fee_total"] / principal if decimals is not None: for key, val in d.items(): if isinstance(val, pd.DataFrame): d[key] = val.round(decimals) else: d[key] = round(val, 2) return d def aggregate_payment_schedules( payment_schedules: List[DataFrame], start_date: str = None, end_date: str = None, freq: str = None, ) -> DataFrame: """ Given a list of payment schedules in the form output by the function :func:`summarize_amortized_loan` do the following. Concatenate the payment schedules. If all the schedules have a payment date column, then slice to the given start date and end date (inclusive), group by payment date, and resample at the given Pandas frequency (not a frequency in :const:`NUM_BY_FREQ`) by summing. Otherwise, group by payment sequence and sum. Return resulting DataFrame with the columns - ``"payment_date"``: (optional) Numpy Datetime object; present only if all given payment schedules have a payment_date column - ``"payment_sequence"``: (optional) integer; present only if not all the given payment schedules have a payment_date column - ``"principal_payment"`` - ``"interest_payment"`` - ``"fee_payment"`` - ``"total_payment"``: sum of principal_payment, interest_payment, and fee_payment - ``"principal_payment_cumsum"``: cumulative sum of principal_payment - ``"interest_payment_cumsum"``: cumulative sum of interest_payment - ``"fee_payment_cumsum"``: cumulative sum of fee_payment """ if not payment_schedules: raise ValueError("No payment schedules given to aggregate") if all(["payment_date" in f.columns for f in payment_schedules]): # Group by payment date g = ( pd.concat(payment_schedules) .filter( ["payment_date", "principal_payment", "interest_payment", "fee_payment"] ) # Slice .set_index("payment_date") .loc[start_date:end_date] .reset_index() .groupby(pd.Grouper(key="payment_date", freq=freq)) .sum() .sort_index() .reset_index() ) else: # Group by payment sequence g = ( pd.concat(payment_schedules) .filter( [ "payment_sequence", "principal_payment", "interest_payment", "fee_payment", ] ) .groupby("payment_sequence") .sum() .sort_index() .reset_index() ) # Append total payment column return ( g.assign( total_payment=lambda x: ( x.principal_payment + x.interest_payment + x.fee_payment ) ) .assign(principal_payment_cumsum=lambda x: x.principal_payment.cumsum()) .assign(interest_payment_cumsum=lambda x: x.interest_payment.cumsum()) .assign(fee_payment_cumsum=lambda x: x.fee_payment.cumsum()) .assign(total_payment_cumsum=lambda x: x.total_payment.cumsum()) ) PK!mpayulator/loan.py""" Module defining the Loan class. """ from collections import OrderedDict from pathlib import Path import numbers import json from typing import Optional, Dict import pandas as pd import voluptuous as vt from dataclasses import dataclass from . import constants as cs from . import helpers as hp @dataclass class Loan: """ Represents a loan. Parameters are - ``code``: code name for the loan; defaults to a timestamp-based code - ``kind``: kind of loan; 'amortized' or 'interest_only' - ``principal``: amount of loan, that is, the principal - ``interest_rate``: nominal annual interest rate (not as a percentage), e.g. 0.1 for 10% - ``payment_freq``: payments frequency; one of the keys of :const:`NUM_BY_FREQ`, e.g. 'monthly' - ``compounding_freq``: compounding frequency; one of the keys of :const:`NUM_BY_FREQ`, e.g. 'monthly' - ``num_payments``: number of payments in the loan term - ``fee``: loan fee - ``first_payment_date``: (YYYY-MM-DD date string) date of first loan payment """ code: Optional[str] = None kind: str = "amortized" principal: float = 1 interest_rate: float = 0 payment_freq: str = "monthly" compounding_freq: Optional[str] = None num_payments: int = 1 fee: float = 0 first_payment_date: Optional[str] = None def __post_init__(self): # Set some defaults if self.code is None: timestamp = pd.datetime.now().strftime("%Y-%m-%dT%H:%M:%S") self.code = f"loan-{timestamp}" if self.compounding_freq is None: self.compounding_freq = self.payment_freq def to_dict(self) -> Dict: """ Return a dictionary of this Loan's attributes. """ return self.__dict__ def summarize(self, decimals: int = 2) -> Dict: """ Return the result of :func:`helpers.summarize_amortized_loan` is this Loan is amortized or :func:`helpers.summarize_interest_only_loan` if this Loan is interest-only. """ result = {} if self.kind == "amortized": result = hp.summarize_amortized_loan( self.principal, self.interest_rate, self.compounding_freq, self.payment_freq, self.num_payments, self.fee, self.first_payment_date, decimals=decimals, ) elif self.kind == "interest_only": result = hp.summarize_interest_only_loan( self.principal, self.interest_rate, self.payment_freq, self.num_payments, self.fee, self.first_payment_date, decimals=decimals, ) return result def check_loan_params(params: Dict) -> Dict: """ Given a dictionary of the form loan_attribute -> value, check the keys and values according to the specification in the docstring of :func:`build_loan` (allowing extra keys). Raise an error if the specification is not met. Otherwise return the dictionary unchanged. """ def check_kind(value): kinds = ["amortized", "interest_only"] if value in kinds: return value raise vt.Invalid("Kind must be one on {}".format(kinds)) def check_pos(value): if isinstance(value, numbers.Number) and value > 0: return value raise vt.Invalid("Not a positive number") def check_nneg(value): if isinstance(value, numbers.Number) and value >= 0: return value raise vt.Invalid("Not a nonnegative number") def check_pos_int(value): if isinstance(value, int) and value > 0: return value raise vt.Invalid("Not a positive integer") def check_freq(value): if value in cs.NUM_BY_FREQ: return value raise vt.Invalid("Frequncy must be one of {}".format(cs.NUM_BY_FREQ.keys())) def check_date(value, fmt="%Y-%m-%d"): err = vt.Invalid("Not a datestring of the form {}".format(fmt)) try: if value is not None and pd.to_datetime(value, format=fmt): return value raise err except TypeError: raise err schema = vt.Schema( { "code": str, "kind": check_kind, "principal": check_pos, "interest_rate": check_nneg, "payment_freq": check_freq, vt.Optional("compounding_freq"): check_freq, "num_payments": check_pos_int, "first_payment_date": check_date, "fee": check_nneg, }, required=True, extra=vt.ALLOW_EXTRA, ) params = schema(params) return params def prune_loan_params(params: Dict) -> Dict: """ Given a dictionary of loan parameters, remove the keys not in :const:`LOAN_ATTRS` and return the resulting, possibly empty dictionary. """ new_params = {} for key in params: if key in cs.LOAN_ATTRS: new_params[key] = params[key] return new_params def build_loan(path: Path) -> "Loan": """ Given the path to a JSON file encoding the parameters of a loan, read the file, check the parameters, and return a Loan instance with those parameters. The keys and values of the JSON dictionary should contain - ``code``: (string) code name for the loan - ``kind``: (string) kind of loan; 'amortized' or 'interest_only' - ``principal``: (float) amount of loan, that is, the principal - ``interest_rate``: (float) nominal annual interest rate (not as a percentage), e.g. 0.1 for 10% - ``payment_freq``: (string) payments frequency; one of the keys of :const:`NUM_BY_FREQ`, e.g. 'monthly' - ``compounding_freq``: (string) compounding frequency; one of the keys of :const:`NUM_BY_FREQ`, e.g. 'monthly' - ``num_payments``: (integer) number of payments in the loan term Extra keys and values are allowed but will be ignored in the returned Loan instance. """ # Read file with Path(path).open() as src: params = json.load(src) # Check params params = check_loan_params(params) # Remove extra params params = prune_loan_params(params) return Loan(**params) PK!"Wz88%payulator-3.2.0.dist-info/LICENSE.txtThe MIT License (MIT) Copyright (c) 2018 Alex Raichev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PK!HڽTUpayulator-3.2.0.dist-info/WHEEL A н#Z;/"d&F[xzw@Zpy3Fv]\fi4WZ^EgM_-]#0(q7PK!Hb"payulator-3.2.0.dist-info/METADATAU]S6 }n[⛄6-- w`iO\'gc; +~<%tt$)ЋRxI3Hyn ZwƲ>Oy;38ikhP0 VTp?7Z|; Y8#TB6.FNZt"pg{h]49$0@ep{mkM[.Sk*+k1Ez=?z:K|;EANc~㽓]VRfO]z4];ӍIqKtGF{>ܷ6/~YaT޷d\-7䢬yŀ [4XlDÑb.]T!k ]ԧJ8598%f[m~0䫨~{d#,xCyFvdv %g^lM8Q6.k#g]i牵eZق\ܯ'~6#GU^i#zxPmC)J=|ٵ=ea&^瓥-> _QpJ΀ yE#/a>쯃`P 8$nqc'Q)Pש[ SF]SJFZoKSX֋Lذ?EAh t˒6 =!>͠T:c<G.% FGhE1TXꨣiWQi 8*P:]!QU+Yz\Q ][ j!թ[ηerzyqe7r]HGBi? =ɺio\,= z.ܢrE