Skip to content

API Reference

Loan

Base Loan class supporting various mortgage types.

Parameters

principal : float Original loan amount. term_months : int Loan term in months. rate : float Nominal annual interest rate (APR). origination_date : datetime.date Date the loan originated. loan_type : str, optional Type of loan: 'fixed', 'arm', 'fha', 'heloc', 'va', 'usda'. Default is 'fixed'. compounding : str, optional Interest compounding method (e.g., '30E/360', 'A/365F'). Default is '30E/360'. pmi : bool, optional Whether the loan includes Private Mortgage Insurance. Default is False. draw_period_months : int, optional Number of months for interest-only draw period (HELOCs). repayment_term_months : int, optional Repayment phase after draw period (HELOCs). extra_payment_amount : float or Decimal, optional Fixed recurring extra payment amount. extra_payment_frequency : str, optional Frequency of extra payments ('monthly', 'biweekly').

Source code in mortgagemodeler/loan.py
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
class Loan:
    """Base Loan class supporting various mortgage types.

    Parameters
    ----------
    principal : float
        Original loan amount.
    term_months : int
        Loan term in months.
    rate : float
        Nominal annual interest rate (APR).
    origination_date : datetime.date
        Date the loan originated.
    loan_type : str, optional
        Type of loan: 'fixed', 'arm', 'fha', 'heloc', 'va', 'usda'. Default is 'fixed'.
    compounding : str, optional
        Interest compounding method (e.g., '30E/360', 'A/365F'). Default is '30E/360'.
    pmi : bool, optional
        Whether the loan includes Private Mortgage Insurance. Default is False.
    draw_period_months : int, optional
        Number of months for interest-only draw period (HELOCs).
    repayment_term_months : int, optional
        Repayment phase after draw period (HELOCs).
    extra_payment_amount : float or Decimal, optional
        Fixed recurring extra payment amount.
    extra_payment_frequency : str, optional
        Frequency of extra payments ('monthly', 'biweekly').
    """
    def __init__(self, principal, term_months, rate, origination_date: date,
                 loan_type='fixed', compounding='30E/360', pmi=False,
                 draw_period_months: Optional[int] = None, repayment_term_months: Optional[int] = None,
                 extra_payment_amount: Optional[Union[float, Decimal]] = None,
                 extra_payment_frequency: Optional[str] = None):
        self.principal = Decimal(principal)
        self.original_balance = Decimal(principal)
        self.term = term_months
        self.rate = Decimal(rate)
        self.origination_date = origination_date
        self.loan_type = loan_type
        self.compounding = compounding
        self.is_pmi = pmi

        # ARM attributes
        self.index = None
        self.margin = Decimal("0.00")
        self.arm_structure = None
        self.caps = {'initial': None, 'periodic': None, 'lifetime': None}

        # HELOC support
        self.draw_period_months = draw_period_months or 0
        self.repayment_term_months = repayment_term_months or 0
        self.is_heloc = loan_type == 'heloc'

        # FHA support
        self.is_fha = loan_type == 'fha'
        self.fha_upfront = Decimal("0.0175")  # 1.75% upfront MIP default
        self.fha_monthly = Decimal("0.0085") / 12  # 0.85% annualized
        if self.is_fha:
            self.principal += self.principal * self.fha_upfront

        # VA/USDA support
        self.is_va = loan_type == 'va'
        self.is_usda = loan_type == 'usda'
        self.guarantee_fee = Decimal("0.0225") if self.is_va else Decimal("0.01")
        self.usda_annual_fee = Decimal("0.0035") if self.is_usda else Decimal("0.00")
        if self.is_va or self.is_usda:
            self.principal += self.principal * self.guarantee_fee
        if self.is_va or self.is_usda:
            self.is_pmi = False

        # Extra payment
        self.extra_payment_amount = Decimal(extra_payment_amount) if extra_payment_amount else Decimal("0.00")
        self.extra_payment_frequency = extra_payment_frequency

    @classmethod
    def from_fha(cls, principal, term, rate, origination_date):
        """Construct an FHA loan object."""
        return cls(principal, term, rate, origination_date, loan_type='fha')

    @classmethod
    def from_va(cls, principal, term, rate, origination_date):
        """Construct a VA loan object."""
        return cls(principal, term, rate, origination_date, loan_type='va')

    @classmethod
    def from_usda(cls, principal, term, rate, origination_date):
        """Construct a USDA loan object."""
        return cls(principal, term, rate, origination_date, loan_type='usda')

    @classmethod
    def from_arm_type(cls, arm_type: str, principal, term, rate, origination_date):
        """Construct an ARM loan from a hybrid ARM string (e.g., '5/6').

        Parameters
        ----------
        arm_type : str
            Format is '{fixed_period}/{adjustment_freq}' in years/months.
        """
        fixed, freq = map(int, arm_type.split('/'))
        loan = cls(principal, term, rate, origination_date, loan_type='arm')
        loan.arm_structure = (fixed, freq)
        return loan

    def set_indexed_rate(self, index_name: str, margin: float, caps=(2, 1, 5)):
        """Configure index-based rate adjustment (for ARMs or HELOCs).

        Parameters
        ----------
        index_name : str
            Name of the index (e.g., 'SOFR', 'PRIME').
        margin : float
            Rate margin added to index.
        caps : tuple
            Tuple of (initial cap, periodic cap, lifetime cap).
        """
        self.index = index_name.upper()
        self.margin = Decimal(margin)
        self.caps = {'initial': caps[0], 'periodic': caps[1], 'lifetime': caps[2]}

    def refinance(self, new_rate: float, refinance_date: date, new_term: Optional[int] = None, fees: float = 0.0):
        """Creates a new Loan object simulating a refinance at a given date.

        Parameters
        ----------
        new_rate : float
            New interest rate.
        refinance_date : date
            Date of refinance (must match amortizer schedule).
        new_term : int, optional
            Optional new loan term in months.
        fees : float, optional
            Optional closing costs added to balance.

        Returns
        -------
        Loan
            New refinanced Loan object.
        """
        return Loan(
            principal=self.principal + Decimal(fees),
            term_months=new_term or self.term,
            rate=new_rate,
            origination_date=refinance_date,
            loan_type=self.loan_type,
            compounding=self.compounding,
            pmi=self.is_pmi,
            draw_period_months=self.draw_period_months,
            repayment_term_months=self.repayment_term_months,
            extra_payment_amount=float(self.extra_payment_amount),
            extra_payment_frequency=self.extra_payment_frequency
        )

    def recast(self, lump_sum: float, recast_date: date):
        """Apply a lump-sum principal reduction and update loan balance.

        Parameters
        ----------
        lump_sum : float
            Amount to reduce from principal.
        recast_date : date
            Date the recast is executed.
        """
        self.principal -= Decimal(lump_sum)
        self.origination_date = recast_date
        self.original_balance = self.principal

    def apply_extra_payment(self, amount: float, frequency: str):
        """Set up recurring extra payments.

        Parameters
        ----------
        amount : float
            Extra payment amount to apply.
        frequency : str
            Payment frequency, e.g., 'monthly', 'biweekly'.
        """
        self.extra_payment_amount = Decimal(str(amount))
        self.extra_payment_frequency = frequency

    def to_dict(self):
        """Return core loan parameters as dictionary (for CLI or plotting use)."""
        return {
            "principal": float(self.principal),
            "term_months": self.term,
            "rate": float(self.rate),
            "start_date": self.origination_date.isoformat(),
            "type": self.loan_type.upper()
        }

apply_extra_payment(amount, frequency)

Set up recurring extra payments.

Parameters

amount : float Extra payment amount to apply. frequency : str Payment frequency, e.g., 'monthly', 'biweekly'.

Source code in mortgagemodeler/loan.py
172
173
174
175
176
177
178
179
180
181
182
183
def apply_extra_payment(self, amount: float, frequency: str):
    """Set up recurring extra payments.

    Parameters
    ----------
    amount : float
        Extra payment amount to apply.
    frequency : str
        Payment frequency, e.g., 'monthly', 'biweekly'.
    """
    self.extra_payment_amount = Decimal(str(amount))
    self.extra_payment_frequency = frequency

from_arm_type(arm_type, principal, term, rate, origination_date) classmethod

Construct an ARM loan from a hybrid ARM string (e.g., '5/6').

Parameters

arm_type : str Format is '{fixed_period}/{adjustment_freq}' in years/months.

Source code in mortgagemodeler/loan.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
@classmethod
def from_arm_type(cls, arm_type: str, principal, term, rate, origination_date):
    """Construct an ARM loan from a hybrid ARM string (e.g., '5/6').

    Parameters
    ----------
    arm_type : str
        Format is '{fixed_period}/{adjustment_freq}' in years/months.
    """
    fixed, freq = map(int, arm_type.split('/'))
    loan = cls(principal, term, rate, origination_date, loan_type='arm')
    loan.arm_structure = (fixed, freq)
    return loan

from_fha(principal, term, rate, origination_date) classmethod

Construct an FHA loan object.

Source code in mortgagemodeler/loan.py
80
81
82
83
@classmethod
def from_fha(cls, principal, term, rate, origination_date):
    """Construct an FHA loan object."""
    return cls(principal, term, rate, origination_date, loan_type='fha')

from_usda(principal, term, rate, origination_date) classmethod

Construct a USDA loan object.

Source code in mortgagemodeler/loan.py
90
91
92
93
@classmethod
def from_usda(cls, principal, term, rate, origination_date):
    """Construct a USDA loan object."""
    return cls(principal, term, rate, origination_date, loan_type='usda')

from_va(principal, term, rate, origination_date) classmethod

Construct a VA loan object.

Source code in mortgagemodeler/loan.py
85
86
87
88
@classmethod
def from_va(cls, principal, term, rate, origination_date):
    """Construct a VA loan object."""
    return cls(principal, term, rate, origination_date, loan_type='va')

recast(lump_sum, recast_date)

Apply a lump-sum principal reduction and update loan balance.

Parameters

lump_sum : float Amount to reduce from principal. recast_date : date Date the recast is executed.

Source code in mortgagemodeler/loan.py
158
159
160
161
162
163
164
165
166
167
168
169
170
def recast(self, lump_sum: float, recast_date: date):
    """Apply a lump-sum principal reduction and update loan balance.

    Parameters
    ----------
    lump_sum : float
        Amount to reduce from principal.
    recast_date : date
        Date the recast is executed.
    """
    self.principal -= Decimal(lump_sum)
    self.origination_date = recast_date
    self.original_balance = self.principal

refinance(new_rate, refinance_date, new_term=None, fees=0.0)

Creates a new Loan object simulating a refinance at a given date.

Parameters

new_rate : float New interest rate. refinance_date : date Date of refinance (must match amortizer schedule). new_term : int, optional Optional new loan term in months. fees : float, optional Optional closing costs added to balance.

Returns

Loan New refinanced Loan object.

Source code in mortgagemodeler/loan.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def refinance(self, new_rate: float, refinance_date: date, new_term: Optional[int] = None, fees: float = 0.0):
    """Creates a new Loan object simulating a refinance at a given date.

    Parameters
    ----------
    new_rate : float
        New interest rate.
    refinance_date : date
        Date of refinance (must match amortizer schedule).
    new_term : int, optional
        Optional new loan term in months.
    fees : float, optional
        Optional closing costs added to balance.

    Returns
    -------
    Loan
        New refinanced Loan object.
    """
    return Loan(
        principal=self.principal + Decimal(fees),
        term_months=new_term or self.term,
        rate=new_rate,
        origination_date=refinance_date,
        loan_type=self.loan_type,
        compounding=self.compounding,
        pmi=self.is_pmi,
        draw_period_months=self.draw_period_months,
        repayment_term_months=self.repayment_term_months,
        extra_payment_amount=float(self.extra_payment_amount),
        extra_payment_frequency=self.extra_payment_frequency
    )

set_indexed_rate(index_name, margin, caps=(2, 1, 5))

Configure index-based rate adjustment (for ARMs or HELOCs).

Parameters

index_name : str Name of the index (e.g., 'SOFR', 'PRIME'). margin : float Rate margin added to index. caps : tuple Tuple of (initial cap, periodic cap, lifetime cap).

Source code in mortgagemodeler/loan.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def set_indexed_rate(self, index_name: str, margin: float, caps=(2, 1, 5)):
    """Configure index-based rate adjustment (for ARMs or HELOCs).

    Parameters
    ----------
    index_name : str
        Name of the index (e.g., 'SOFR', 'PRIME').
    margin : float
        Rate margin added to index.
    caps : tuple
        Tuple of (initial cap, periodic cap, lifetime cap).
    """
    self.index = index_name.upper()
    self.margin = Decimal(margin)
    self.caps = {'initial': caps[0], 'periodic': caps[1], 'lifetime': caps[2]}

to_dict()

Return core loan parameters as dictionary (for CLI or plotting use).

Source code in mortgagemodeler/loan.py
185
186
187
188
189
190
191
192
193
def to_dict(self):
    """Return core loan parameters as dictionary (for CLI or plotting use)."""
    return {
        "principal": float(self.principal),
        "term_months": self.term,
        "rate": float(self.rate),
        "start_date": self.origination_date.isoformat(),
        "type": self.loan_type.upper()
    }

LoanAmortizer

LoanAmortizer builds an amortization schedule for various loan types including: - Fixed Rate Loans - Adjustable Rate Mortgages (ARMs) - FHA Loans with MIP - VA and USDA Loans with Guarantee Fees - HELOCs with draw and repayment phases

It supports: - Custom forward rate schedules - Insurance premiums (PMI, MIP, USDA annual fee) - Decimal-based precision for all financial calculations

Parameters

loan : Loan An instance of the Loan class with fully defined parameters. custom_rate_schedule : dict, optional A dictionary mapping date strings ("YYYY-MM-DD") to interest rate overrides.

Source code in mortgagemodeler/amortizer.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
class LoanAmortizer:
    """
    LoanAmortizer builds an amortization schedule for various loan types including:
    - Fixed Rate Loans
    - Adjustable Rate Mortgages (ARMs)
    - FHA Loans with MIP
    - VA and USDA Loans with Guarantee Fees
    - HELOCs with draw and repayment phases

    It supports:
    - Custom forward rate schedules
    - Insurance premiums (PMI, MIP, USDA annual fee)
    - Decimal-based precision for all financial calculations

    Parameters
    ----------
    loan : Loan
        An instance of the Loan class with fully defined parameters.
    custom_rate_schedule : dict, optional
        A dictionary mapping date strings ("YYYY-MM-DD") to interest rate overrides.
    """
    def __init__(self, loan, custom_rate_schedule=None):
        self.loan = loan
        self.schedule = []
        self.custom_rate_schedule = custom_rate_schedule or {}
        self.rates = RateReader()
        self._build_amortization()

    def _get_effective_rate(self, date_str) -> Decimal:
        """
        Returns the effective interest rate for a given date.
        - Uses fixed rate if applicable
        - Uses indexed rate + margin for ARM/HELOC
        - Honors custom rate schedule overrides

        Parameters
        ----------
        date_str : str
            Date in YYYY-MM-DD format.

        Returns
        -------
        Decimal
            Effective interest rate as a Decimal.
        """
        if self.loan.loan_type == 'fixed':
            return self.loan.rate
        elif self.loan.loan_type in ['arm', 'heloc'] and self.loan.index:
            raw_rate = self.rates.get_rate(self.loan.index, date_str)
            return (Decimal(str(raw_rate)) + self.loan.margin).quantize(Decimal("0.0001"))
        else:
            return self.loan.rate

    def _calculate_insurance(self, balance: Decimal, month: int) -> Decimal:
        """
        Calculate monthly insurance (MIP, PMI, or USDA annual fee) based on loan type.

        Parameters
        ----------
        balance : Decimal
            Current outstanding balance.
        month : int
            Current month number in the loan term.

        Returns
        -------
        Decimal
            Monthly insurance premium amount.
        """
        if self.loan.is_fha:
            return (balance * self.loan.fha_monthly).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        elif self.loan.is_usda:
            return (balance * self.loan.usda_annual_fee / Decimal("12")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        elif self.loan.is_pmi:
            ltv = balance / self.loan.original_balance
            if ltv <= Decimal("0.78") or month > 132:
                return Decimal("0.00")
            return (balance * self.loan.pmi_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        return Decimal("0.00")

    def _build_amortization(self):
        """
        Constructs the full amortization schedule from origination through maturity.
        Handles:
        - Draw period and interest-only payments (HELOC)
        - Full amortization including principal + interest
        - Monthly insurance additions
        - Accurate payment calculations using present value formula
        """
        balance = self.loan.principal
        rate = self.loan.rate
        current_date = self.loan.origination_date
        total_term = self.loan.term
        draw_period = self.loan.draw_period_months
        repay_term = self.loan.repayment_term_months or (total_term - draw_period)

        for month in range(1, total_term + 1):
            current_date += timedelta(days=30)
            date_str = current_date.strftime('%Y-%m-%d')

            rate = Decimal(str(self.custom_rate_schedule.get(date_str, self._get_effective_rate(date_str))))
            interest = Decimal(calculate_interest(balance, rate, 30, self.loan.compounding)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

            if self.loan.is_heloc and month <= draw_period:
                payment = interest
                principal = Decimal("0.00")
            else:
                monthly_rate = rate / Decimal("12") / Decimal("100")
                if self.loan.is_heloc:
                    remaining_term = total_term - draw_period - (month - draw_period - 1)
                else:
                    remaining_term = total_term - month + 1
                payment = (balance * monthly_rate / (1 - (1 + monthly_rate) ** -remaining_term)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
                principal = (payment - interest).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

            insurance = self._calculate_insurance(balance, month)
            total_payment = (payment + insurance).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            ending_balance = (balance - principal).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

            self.schedule.append({
                "Month": month,
                "Date": date_str,
                "Beginning Balance": float(balance),
                "Payment": float(payment),
                "Principal": float(principal),
                "Interest": float(interest),
                "PMI/MIP": float(insurance),
                "Total Payment": float(total_payment),
                "Ending Balance": float(ending_balance)
            })

            balance = ending_balance

    def to_dataframe(self):
        """
        Returns the amortization schedule as a pandas DataFrame.

        Returns
        -------
        pd.DataFrame
            Tabular amortization schedule.
        """
        return pd.DataFrame(self.schedule)

    def to_csv(self, filepath):
        """
        Writes the amortization schedule to a CSV file.

        Parameters
        ----------
        filepath : str
            Destination file path.
        """
        self.to_dataframe().to_csv(filepath, index=False)

to_csv(filepath)

Writes the amortization schedule to a CSV file.

Parameters

filepath : str Destination file path.

Source code in mortgagemodeler/amortizer.py
153
154
155
156
157
158
159
160
161
162
def to_csv(self, filepath):
    """
    Writes the amortization schedule to a CSV file.

    Parameters
    ----------
    filepath : str
        Destination file path.
    """
    self.to_dataframe().to_csv(filepath, index=False)

to_dataframe()

Returns the amortization schedule as a pandas DataFrame.

Returns

pd.DataFrame Tabular amortization schedule.

Source code in mortgagemodeler/amortizer.py
142
143
144
145
146
147
148
149
150
151
def to_dataframe(self):
    """
    Returns the amortization schedule as a pandas DataFrame.

    Returns
    -------
    pd.DataFrame
        Tabular amortization schedule.
    """
    return pd.DataFrame(self.schedule)

RateReader

Utility class to read and serve forward interest rates from macroeconomic projections. Supports Prime, LIBOR, SOFR, MTA, CMT, and fallback to FRED codes.

Source code in utils/rates.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class RateReader:
    """
    Utility class to read and serve forward interest rates from macroeconomic projections.
    Supports Prime, LIBOR, SOFR, MTA, CMT, and fallback to FRED codes.
    """

    SUPPORTED_INDICES = {
        'LIBOR1': 'LIBOR1MonRate',
        'LIBOR3': 'LIBOR3MonRate',
        'LIBOR6': 'LIBOR6MonRate',
        'LIBOR12': 'LIBOR12MonRate',
        'PRIME': 'PrimeRate',
        'MTA': 'TreasAvg',
        'CMT12': 'CMT1YearRate',
        'SOFR': 'SOFR30DAYAVG'
    }

    def __init__(self, filepath: Optional[str] = None):
        """
        Initialize the rate reader.

        Parameters
        ----------
        filepath : str, optional
            Path to the rate projection file. If not provided, defaults to 'data/macroeconforward.txt'.
        """
        self.filepath = filepath or os.path.join(os.path.dirname(__file__), "../data", "macroeconforward.txt")
        self.rates_df = self._load_rates()

    def _load_rates(self) -> pd.DataFrame:
        if self.filepath.endswith(".csv"):
            return pd.read_csv(self.filepath, sep=None, engine='python')
        else:
            return pd.read_csv(self.filepath, sep='\t')

    def get_rate(self, index: str, date_str: str) -> float:
        """
        Retrieve the forward rate for a specific index and date.

        Parameters
        ----------
        index : str
            The macroeconomic index (e.g., 'PRIME', 'LIBOR12').
        date_str : str
            Date string in format 'YYYY-MM-DD'.

        Returns
        -------
        float
            Forward interest rate.
        """
        index_col = self.SUPPORTED_INDICES.get(index.upper())
        if index_col is None:
            raise ValueError(f"Unsupported index: {index}. Supported: {list(self.SUPPORTED_INDICES.keys())}")

        row = self.rates_df[self.rates_df.iloc[:, 0] == date_str]
        if row.empty:
            raise KeyError(f"Date {date_str} not found in rate file.")

        return float(row[index_col].values[0])

__init__(filepath=None)

Initialize the rate reader.

Parameters

filepath : str, optional Path to the rate projection file. If not provided, defaults to 'data/macroeconforward.txt'.

Source code in utils/rates.py
24
25
26
27
28
29
30
31
32
33
34
def __init__(self, filepath: Optional[str] = None):
    """
    Initialize the rate reader.

    Parameters
    ----------
    filepath : str, optional
        Path to the rate projection file. If not provided, defaults to 'data/macroeconforward.txt'.
    """
    self.filepath = filepath or os.path.join(os.path.dirname(__file__), "../data", "macroeconforward.txt")
    self.rates_df = self._load_rates()

get_rate(index, date_str)

Retrieve the forward rate for a specific index and date.

Parameters

index : str The macroeconomic index (e.g., 'PRIME', 'LIBOR12'). date_str : str Date string in format 'YYYY-MM-DD'.

Returns

float Forward interest rate.

Source code in utils/rates.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def get_rate(self, index: str, date_str: str) -> float:
    """
    Retrieve the forward rate for a specific index and date.

    Parameters
    ----------
    index : str
        The macroeconomic index (e.g., 'PRIME', 'LIBOR12').
    date_str : str
        Date string in format 'YYYY-MM-DD'.

    Returns
    -------
    float
        Forward interest rate.
    """
    index_col = self.SUPPORTED_INDICES.get(index.upper())
    if index_col is None:
        raise ValueError(f"Unsupported index: {index}. Supported: {list(self.SUPPORTED_INDICES.keys())}")

    row = self.rates_df[self.rates_df.iloc[:, 0] == date_str]
    if row.empty:
        raise KeyError(f"Date {date_str} not found in rate file.")

    return float(row[index_col].values[0])

Utility Functions

Effective APR

Estimate Effective APR using cost-adjusted loan proceeds and IRR method.

Parameters

principal : float Loan amount. rate : float Nominal APR. term_months : int Loan term in months. points : float Discount points as percent of loan (e.g., 1.0 for 1 point). fees : float Closing costs not rolled into the loan.

Returns

float Effective APR expressed as annual percent.

Source code in utils/effective_apr.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def effective_apr(principal, rate, term_months, points=0.0, fees=0.0):
    """Estimate Effective APR using cost-adjusted loan proceeds and IRR method.

    Parameters
    ----------
    principal : float
        Loan amount.
    rate : float
        Nominal APR.
    term_months : int
        Loan term in months.
    points : float
        Discount points as percent of loan (e.g., 1.0 for 1 point).
    fees : float
        Closing costs not rolled into the loan.

    Returns
    -------
    float
        Effective APR expressed as annual percent.
    """
    from numpy_financial import irr
    from decimal import Decimal

    loan_amount = Decimal(str(principal))
    upfront_cost = loan_amount * Decimal(str(points)) / Decimal("100") + Decimal(str(fees))
    net_proceeds = loan_amount - upfront_cost

    monthly_payment = (loan_amount * Decimal(str(rate)) / Decimal("1200")) / \
                      (1 - (1 + Decimal(str(rate)) / Decimal("1200")) ** -term_months)
    cash_flows = [-float(net_proceeds)] + [float(monthly_payment)] * term_months

    internal_rate = irr(cash_flows)
    if internal_rate is None:
        return None

    return round((1 + internal_rate) ** 12 - 1, 5) * 100  # Convert monthly IRR to annual %

Breakeven Analysis

Compute breakeven month when refinancing pays off via cumulative monthly savings.

Parameters

refi_df : pd.DataFrame Amortization table for refinanced loan. base_df : pd.DataFrame Amortization table for original loan. refi_costs : float Closing/refinance costs incurred upfront.

Returns

dict Contains breakeven month and cumulative savings profile.

Source code in utils/breakeven.py
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def breakeven_analysis(refi_df, base_df, refi_costs=0.0):
    """Compute breakeven month when refinancing pays off via cumulative monthly savings.

    Parameters
    ----------
    refi_df : pd.DataFrame
        Amortization table for refinanced loan.
    base_df : pd.DataFrame
        Amortization table for original loan.
    refi_costs : float
        Closing/refinance costs incurred upfront.

    Returns
    -------
    dict
        Contains breakeven month and cumulative savings profile.
    """
    merged = pd.merge(
        base_df[['Month', 'Total Payment']],
        refi_df[['Month', 'Total Payment']],
        on='Month',
        suffixes=('_base', '_refi')
    )

    merged['Monthly Savings'] = merged['Total Payment_base'] - merged['Total Payment_refi']
    merged['Cumulative Savings'] = merged['Monthly Savings'].cumsum()
    merged['Net Savings'] = merged['Cumulative Savings'] - refi_costs

    breakeven_months = merged[merged['Net Savings'] > 0]['Month']
    breakeven_month = int(breakeven_months.iloc[0]) if not breakeven_months.empty else None

    return {
        'breakeven_month': breakeven_month,
        'net_savings': round(merged['Net Savings'].iloc[-1], 2),
        'monthly_savings': merged[['Month', 'Monthly Savings', 'Net Savings']]
    }

Plotting

Plot the amortization schedule: balances and payment components.

Parameters

df : pd.DataFrame Output from LoanAmortizer.to_dataframe() title : str Title for the plot. save_path : str or None If provided, saves the plot to file.

Source code in utils/plotting.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def plot_amortization(df: pd.DataFrame, title="Amortization Schedule", save_path=None):
    """
    Plot the amortization schedule: balances and payment components.

    Parameters
    ----------
    df : pd.DataFrame
        Output from LoanAmortizer.to_dataframe()
    title : str
        Title for the plot.
    save_path : str or None
        If provided, saves the plot to file.
    """
    plt.figure(figsize=(12, 6))

    if 'Total Payment' in df.columns:
        payment_col = 'Total Payment'
    else:
        payment_col = 'Payment'

    sns.lineplot(data=df, x="Month", y="Beginning Balance", label="Beginning Balance")
    sns.lineplot(data=df, x="Month", y="Principal", label="Principal")
    sns.lineplot(data=df, x="Month", y="Interest", label="Interest")
    sns.lineplot(data=df, x="Month", y=payment_col, label="Total Payment")

    if "PMI/MIP" in df.columns:
        sns.lineplot(data=df, x="Month", y="PMI/MIP", label="PMI/MIP")

    plt.title(title)
    plt.xlabel("Month")
    plt.ylabel("Amount ($)")
    plt.legend()
    plt.tight_layout()

    if save_path:
        plt.savefig(save_path)
    else:
        plt.show()

Compare two amortization schedules by summarizing key outcome metrics.

Source code in utils/exta_payment_compare.py
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def compare_scenarios(df1, df2):
    """Compare two amortization schedules by summarizing key outcome metrics."""
    def summarize(df):
        return {
            'Total Interest': round(df['Interest'].sum(), 2),
            'Total PMI/MIP': round(df['PMI/MIP'].sum(), 2),
            'Total Payments': round(df['Total Payment'].sum(), 2),
            'Payoff Month': df[df['Ending Balance'] <= 0.01]['Month'].min() or df['Month'].max(),
            'Final Balance': round(df['Ending Balance'].iloc[-1], 2)
        }

    summary1 = summarize(df1)
    summary2 = summarize(df2)
    comparison = pd.DataFrame([summary1, summary2], index=["Scenario 1", "Scenario 2"])
    return comparison