Skip to content

calculations

Financial calculations for refinance analysis.

analyze_refinance(current_balance, current_rate, current_remaining_years, new_rate, new_term_years, closing_costs, opportunity_rate=0.05, npv_window_years=5, chart_horizon_years=10, marginal_tax_rate=0.0, cash_out=0.0, maintain_payment=False)

Analyze refinance scenario.

Parameters:

Name Type Description Default
current_balance float

Current loan balance

required
current_rate float

Current loan interest rate (as a decimal)

required
current_remaining_years float

Current loan remaining term in years

required
new_rate float

New loan interest rate (as a decimal)

required
new_term_years float

New loan term in years

required
closing_costs float

Closing costs for refinance

required
opportunity_rate float

Opportunity cost rate (as a decimal). Default is 5%.

0.05
npv_window_years int

Years to calculate NPV over. Default is 5 years.

5
chart_horizon_years int

Years to show in cumulative savings chart. Default is 10 years.

10
marginal_tax_rate float

Marginal tax rate (as a decimal). Default is 0%.

0.0
cash_out float

Cash out amount from refinance. Default is 0.

0.0
maintain_payment bool

Whether to maintain current payment amount. Default is False.

False

Returns:

Type Description
RefinanceAnalysis

RefinanceAnalysis object with results

Source code in src/refi_calculator/core/calculations.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
def analyze_refinance(
    current_balance: float,
    current_rate: float,
    current_remaining_years: float,
    new_rate: float,
    new_term_years: float,
    closing_costs: float,
    opportunity_rate: float = 0.05,
    npv_window_years: int = 5,
    chart_horizon_years: int = 10,
    marginal_tax_rate: float = 0.0,
    cash_out: float = 0.0,
    maintain_payment: bool = False,
) -> RefinanceAnalysis:
    """Analyze refinance scenario.

    Args:
        current_balance: Current loan balance
        current_rate: Current loan interest rate (as a decimal)
        current_remaining_years: Current loan remaining term in years
        new_rate: New loan interest rate (as a decimal)
        new_term_years: New loan term in years
        closing_costs: Closing costs for refinance
        opportunity_rate: Opportunity cost rate (as a decimal). Default is 5%.
        npv_window_years: Years to calculate NPV over. Default is 5 years.
        chart_horizon_years: Years to show in cumulative savings chart. Default is 10 years.
        marginal_tax_rate: Marginal tax rate (as a decimal). Default is 0%.
        cash_out: Cash out amount from refinance. Default is 0.
        maintain_payment: Whether to maintain current payment amount. Default is False.

    Returns:
        RefinanceAnalysis object with results
    """
    current_loan = LoanParams(
        balance=current_balance,
        rate=current_rate,
        term_years=current_remaining_years,
    )

    new_balance = current_balance + closing_costs + cash_out
    new_loan = LoanParams(
        balance=new_balance,
        rate=new_rate,
        term_years=new_term_years,
    )

    monthly_savings = current_loan.monthly_payment - new_loan.monthly_payment

    simple_breakeven: float | None = None
    if monthly_savings > 0:
        simple_breakeven = closing_costs / monthly_savings

    monthly_opp_rate = opportunity_rate / 12
    chart_months = chart_horizon_years * 12
    cumulative_savings, npv_breakeven = _build_cumulative_savings(
        monthly_savings,
        closing_costs,
        monthly_opp_rate,
        chart_months,
        new_loan.num_payments,
    )

    npv_window_months = npv_window_years * 12
    window_npv = _calculate_npv_window(
        monthly_savings,
        monthly_opp_rate,
        closing_costs,
        npv_window_months,
    )

    current_avg_monthly_interest = current_loan.total_interest / current_loan.num_payments
    new_avg_monthly_interest = new_loan.total_interest / new_loan.num_payments
    current_monthly_tax_benefit = current_avg_monthly_interest * marginal_tax_rate
    new_monthly_tax_benefit = new_avg_monthly_interest * marginal_tax_rate

    current_after_tax_payment = current_loan.monthly_payment - current_monthly_tax_benefit
    new_after_tax_payment = new_loan.monthly_payment - new_monthly_tax_benefit
    after_tax_monthly_savings = current_after_tax_payment - new_after_tax_payment

    after_tax_simple_breakeven = None
    if after_tax_monthly_savings > 0:
        after_tax_simple_breakeven = closing_costs / after_tax_monthly_savings

    after_tax_npv_breakeven = _find_npv_breakeven(
        after_tax_monthly_savings,
        monthly_opp_rate,
        closing_costs,
        new_loan.num_payments,
    )

    after_tax_npv = _calculate_npv_window(
        after_tax_monthly_savings,
        monthly_opp_rate,
        closing_costs,
        npv_window_months,
    )

    current_after_tax_total_interest = current_loan.total_interest * (1 - marginal_tax_rate)
    new_after_tax_total_interest = new_loan.total_interest * (1 - marginal_tax_rate)

    accelerated_months: int | None = None
    accelerated_total_interest: float | None = None
    accelerated_interest_savings: float | None = None
    accelerated_time_savings_months: int | None = None

    # Accelerated payoff calculations (if maintaining current payment)
    if maintain_payment and current_loan.monthly_payment > new_loan.monthly_payment:
        # User wants to keep paying the old (higher) amount
        acc_months, acc_interest = calculate_accelerated_payoff(
            new_balance,
            new_rate,
            current_loan.monthly_payment,
        )
        if acc_months:
            accelerated_months = acc_months
            accelerated_total_interest = acc_interest
            if acc_interest is not None:
                accelerated_interest_savings = new_loan.total_interest - acc_interest
            accelerated_time_savings_months = new_loan.num_payments - acc_months

    # Total cost NPV calculations
    current_total_cost_npv = (
        calculate_total_cost_npv(
            current_balance,
            current_rate,
            current_remaining_years,
            opportunity_rate,
        )
        + closing_costs
    )  # Include closing costs in current scenario as sunk cost comparison

    if maintain_payment and current_loan.monthly_payment > new_loan.monthly_payment:
        new_total_cost_npv = calculate_total_cost_npv(
            new_balance,
            new_rate,
            new_term_years,
            opportunity_rate,
            payment_override=current_loan.monthly_payment,
        )
    else:
        new_total_cost_npv = calculate_total_cost_npv(
            new_balance,
            new_rate,
            new_term_years,
            opportunity_rate,
        )

    # Positive = refinancing is cheaper in NPV terms
    total_cost_npv_advantage = current_total_cost_npv - new_total_cost_npv - closing_costs

    return RefinanceAnalysis(
        current_payment=current_loan.monthly_payment,
        new_payment=new_loan.monthly_payment,
        monthly_savings=monthly_savings,
        simple_breakeven_months=simple_breakeven,
        npv_breakeven_months=npv_breakeven,
        current_total_interest=current_loan.total_interest,
        new_total_interest=new_loan.total_interest,
        interest_delta=new_loan.total_interest - current_loan.total_interest,
        five_year_npv=window_npv,
        cumulative_savings=cumulative_savings,
        current_after_tax_payment=current_after_tax_payment,
        new_after_tax_payment=new_after_tax_payment,
        after_tax_monthly_savings=after_tax_monthly_savings,
        after_tax_simple_breakeven_months=after_tax_simple_breakeven,
        after_tax_npv_breakeven_months=after_tax_npv_breakeven,
        after_tax_npv=after_tax_npv,
        current_after_tax_total_interest=current_after_tax_total_interest,
        new_after_tax_total_interest=new_after_tax_total_interest,
        after_tax_interest_delta=new_after_tax_total_interest - current_after_tax_total_interest,
        new_loan_balance=new_balance,
        cash_out_amount=cash_out,
        accelerated_months=accelerated_months,
        accelerated_total_interest=accelerated_total_interest,
        accelerated_interest_savings=accelerated_interest_savings,
        accelerated_time_savings_months=accelerated_time_savings_months,
        current_total_cost_npv=current_total_cost_npv,
        new_total_cost_npv=new_total_cost_npv,
        total_cost_npv_advantage=total_cost_npv_advantage,
    )

calculate_accelerated_payoff(balance, rate, payment)

Calculate months to payoff and total interest when paying more than minimum.

Parameters:

Name Type Description Default
balance float

Loan balance

required
rate float

Annual interest rate (as a decimal)

required
payment float

Monthly payment amount

required

Returns:

Type Description
tuple[int | None, float | None]

Tuple of (months to payoff, total interest paid)

Source code in src/refi_calculator/core/calculations.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
def calculate_accelerated_payoff(
    balance: float,
    rate: float,
    payment: float,
) -> tuple[int | None, float | None]:
    """Calculate months to payoff and total interest when paying more than minimum.

    Args:
        balance: Loan balance
        rate: Annual interest rate (as a decimal)
        payment: Monthly payment amount

    Returns:
        Tuple of (months to payoff, total interest paid)
    """
    if rate == 0:
        months = int(balance / payment) + 1
        return months, 0.0

    monthly_rate = rate / 12
    months = 0
    total_interest = 0.0
    remaining = balance

    min_term_in_months = 0
    max_term_in_months = 50 * 12  # Cap at 50 years
    while remaining > min_term_in_months and months < max_term_in_months:
        interest = remaining * monthly_rate
        principal = min(payment - interest, remaining)
        if principal <= 0:  # Payment doesn't cover interest - would never pay off
            return None, None
        remaining -= principal
        total_interest += interest
        months += 1

    return months, total_interest

calculate_total_cost_npv(balance, rate, term_years, opportunity_rate, payment_override=None)

Calculate NPV of total loan cost (all payments discounted to present).

Parameters:

Name Type Description Default
balance float

Loan balance

required
rate float

Annual interest rate (as a decimal)

required
term_years float

Loan term in years

required
opportunity_rate float

Annual opportunity cost rate (as a decimal)

required
payment_override float | None

Optional monthly payment to use instead of standard

None

Returns:

Type Description
float

NPV of total loan cost

Source code in src/refi_calculator/core/calculations.py
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
def calculate_total_cost_npv(
    balance: float,
    rate: float,
    term_years: float,
    opportunity_rate: float,
    payment_override: float | None = None,
) -> float:
    """Calculate NPV of total loan cost (all payments discounted to present).

    Args:
        balance: Loan balance
        rate: Annual interest rate (as a decimal)
        term_years: Loan term in years
        opportunity_rate: Annual opportunity cost rate (as a decimal)
        payment_override: Optional monthly payment to use instead of standard

    Returns:
        NPV of total loan cost
    """
    loan = LoanParams(
        balance=balance,
        rate=rate,
        term_years=term_years,
    )
    payment = payment_override if payment_override else loan.monthly_payment
    monthly_opp_rate = opportunity_rate / 12

    if payment_override and payment_override > loan.monthly_payment:
        # Accelerated payoff
        months, _ = calculate_accelerated_payoff(
            balance=balance,
            rate=rate,
            payment=payment_override,
        )
        if months is None:
            months = loan.num_payments
    else:
        months = loan.num_payments

    npv = 0.0
    remaining = balance
    monthly_rate = rate / 12

    for month in range(1, months + 1):
        if remaining <= 0:
            break
        interest = remaining * monthly_rate
        actual_payment = min(payment, remaining + interest)
        principal = actual_payment - interest
        remaining -= principal
        npv += actual_payment / ((1 + monthly_opp_rate) ** month)

    return npv

generate_amortization_schedule(loan, label)

Generate Amortization Schedule.

Parameters:

Name Type Description Default
loan LoanParams

LoanParams object

required
label str

Label for the loan (e.g., "Current" or "New")

required

Returns:

Type Description
list[dict]

List of dictionaries with amortization schedule details. Each dictionary contains: - loan: Loan label - month: Month number - year: Year number - payment: Monthly payment amount - principal: Principal portion of payment - interest: Interest portion of payment - balance: Remaining balance after payment

Source code in src/refi_calculator/core/calculations.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
def generate_amortization_schedule(
    loan: LoanParams,
    label: str,
) -> list[dict]:
    """Generate Amortization Schedule.

    Args:
        loan: LoanParams object
        label: Label for the loan (e.g., "Current" or "New")

    Returns:
        List of dictionaries with amortization schedule details. Each dictionary contains:
            - loan: Loan label
            - month: Month number
            - year: Year number
            - payment: Monthly payment amount
            - principal: Principal portion of payment
            - interest: Interest portion of payment
            - balance: Remaining balance after payment
    """
    schedule = []
    balance = loan.balance
    monthly_payment = loan.monthly_payment
    monthly_rate = loan.monthly_rate

    for month in range(1, loan.num_payments + 1):
        interest_payment = balance * monthly_rate
        principal_payment = monthly_payment - interest_payment
        balance -= principal_payment
        if balance < 0:
            principal_payment += balance
            balance = 0
        schedule.append(
            {
                "loan": label,
                "month": month,
                "year": (month - 1) // 12 + 1,
                "payment": monthly_payment,
                "principal": principal_payment,
                "interest": interest_payment,
                "balance": max(0, balance),
            },
        )
    return schedule

generate_amortization_schedule_pair(current_balance, current_rate, current_remaining_years, new_rate, new_term_years, closing_costs, cash_out=0.0, maintain_payment=False)

Produce monthly amortization schedules for the current and new loans.

Parameters:

Name Type Description Default
current_balance float

Current loan balance.

required
current_rate float

Current loan interest rate (as a decimal).

required
current_remaining_years float

Remaining term of the current loan.

required
new_rate float

Proposed refinance rate (as a decimal).

required
new_term_years float

Term for the new loan.

required
closing_costs float

Closing cost amount for the refinance.

required
cash_out float

Cash out amount applied to the refinance.

0.0
maintain_payment bool

Whether the new loan should use the current payment.

False

Returns:

Type Description
list[dict]

Tuple of (current_schedule, new_schedule), where each schedule is a list of

list[dict]

monthly dictionaries matching the structure produced by generate_amortization_schedule.

Source code in src/refi_calculator/core/calculations.py
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
def generate_amortization_schedule_pair(
    current_balance: float,
    current_rate: float,
    current_remaining_years: float,
    new_rate: float,
    new_term_years: float,
    closing_costs: float,
    cash_out: float = 0.0,
    maintain_payment: bool = False,
) -> tuple[list[dict], list[dict]]:
    """Produce monthly amortization schedules for the current and new loans.

    Args:
        current_balance: Current loan balance.
        current_rate: Current loan interest rate (as a decimal).
        current_remaining_years: Remaining term of the current loan.
        new_rate: Proposed refinance rate (as a decimal).
        new_term_years: Term for the new loan.
        closing_costs: Closing cost amount for the refinance.
        cash_out: Cash out amount applied to the refinance.
        maintain_payment: Whether the new loan should use the current payment.

    Returns:
        Tuple of (current_schedule, new_schedule), where each schedule is a list of
        monthly dictionaries matching the structure produced by ``generate_amortization_schedule``.
    """
    current_loan = LoanParams(current_balance, current_rate, current_remaining_years)
    new_balance = current_balance + closing_costs + cash_out
    new_loan = LoanParams(new_balance, new_rate, new_term_years)

    current_schedule = generate_amortization_schedule(current_loan, "Current")

    if maintain_payment and current_loan.monthly_payment > new_loan.monthly_payment:
        schedule: list[dict] = []
        balance = new_balance
        monthly_payment = current_loan.monthly_payment
        monthly_rate = new_rate / 12

        month = 0
        max_months = 600
        while balance > 0 and month < max_months:
            month += 1
            interest_payment = balance * monthly_rate
            principal_payment = monthly_payment - interest_payment
            balance -= principal_payment
            if balance < 0:
                principal_payment += balance
                balance = 0
            schedule.append(
                {
                    "loan": "New",
                    "month": month,
                    "year": (month - 1) // 12 + 1,
                    "payment": monthly_payment,
                    "principal": principal_payment,
                    "interest": interest_payment,
                    "balance": max(0, balance),
                },
            )
        new_schedule = schedule
    else:
        new_schedule = generate_amortization_schedule(new_loan, "New")

    return current_schedule, new_schedule

generate_comparison_schedule(current_balance, current_rate, current_remaining_years, new_rate, new_term_years, closing_costs, cash_out=0.0, maintain_payment=False)

Generate Comparison Amortization Schedule between current and new loan.

Parameters:

Name Type Description Default
current_balance float

Current loan balance

required
current_rate float

Current loan interest rate (as a decimal)

required
current_remaining_years float

Current loan remaining term in years

required
new_rate float

New loan interest rate (as a decimal)

required
new_term_years float

New loan term in years

required
closing_costs float

Closing costs for refinance

required
cash_out float

Cash out amount from refinance. Default is 0.

0.0
maintain_payment bool

Whether to maintain current payment amount. Default is False.

False

Returns:

Type Description
list[dict]

List of dictionaries comparing current and new loan amortization schedules by year.

list[dict]

Each dictionary contains: - year: Year number - current_principal: Total principal paid in current loan that year - current_interest: Total interest paid in current loan that year - current_balance: Remaining balance on current loan at year end - new_principal: Total principal paid in new loan that year - new_interest: Total interest paid in new loan that year - new_balance: Remaining balance on new loan at year end - principal_diff: Difference in principal paid (new - current) - interest_diff: Difference in interest paid (new - current) - balance_diff: Difference in balance (new - current)

Source code in src/refi_calculator/core/calculations.py
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
def generate_comparison_schedule(
    current_balance: float,
    current_rate: float,
    current_remaining_years: float,
    new_rate: float,
    new_term_years: float,
    closing_costs: float,
    cash_out: float = 0.0,
    maintain_payment: bool = False,
) -> list[dict]:
    """Generate Comparison Amortization Schedule between current and new loan.

    Args:
        current_balance: Current loan balance
        current_rate: Current loan interest rate (as a decimal)
        current_remaining_years: Current loan remaining term in years
        new_rate: New loan interest rate (as a decimal)
        new_term_years: New loan term in years
        closing_costs: Closing costs for refinance
        cash_out: Cash out amount from refinance. Default is 0.
        maintain_payment: Whether to maintain current payment amount. Default is False.

    Returns:
        List of dictionaries comparing current and new loan amortization schedules by year.
        Each dictionary contains:
            - year: Year number
            - current_principal: Total principal paid in current loan that year
            - current_interest: Total interest paid in current loan that year
            - current_balance: Remaining balance on current loan at year end
            - new_principal: Total principal paid in new loan that year
            - new_interest: Total interest paid in new loan that year
            - new_balance: Remaining balance on new loan at year end
            - principal_diff: Difference in principal paid (new - current)
            - interest_diff: Difference in interest paid (new - current)
            - balance_diff: Difference in balance (new - current)
    """
    current_schedule, new_schedule = generate_amortization_schedule_pair(
        current_balance=current_balance,
        current_rate=current_rate,
        current_remaining_years=current_remaining_years,
        new_rate=new_rate,
        new_term_years=new_term_years,
        closing_costs=closing_costs,
        cash_out=cash_out,
        maintain_payment=maintain_payment,
    )

    # Determine the number of years to show based on the actual schedules
    max_years = max(
        max((s["year"] for s in current_schedule), default=0),
        max((s["year"] for s in new_schedule), default=0),
    )
    comparison = []

    for year in range(1, max_years + 1):
        current_year_data = [s for s in current_schedule if s["year"] == year]
        new_year_data = [s for s in new_schedule if s["year"] == year]

        current_principal = sum(s["principal"] for s in current_year_data)
        current_interest = sum(s["interest"] for s in current_year_data)
        current_end_balance = current_year_data[-1]["balance"] if current_year_data else 0

        new_principal = sum(s["principal"] for s in new_year_data)
        new_interest = sum(s["interest"] for s in new_year_data)
        new_end_balance = new_year_data[-1]["balance"] if new_year_data else 0

        comparison.append(
            {
                "year": year,
                "current_principal": current_principal,
                "current_interest": current_interest,
                "current_balance": current_end_balance,
                "new_principal": new_principal,
                "new_interest": new_interest,
                "new_balance": new_end_balance,
                "principal_diff": new_principal - current_principal,
                "interest_diff": new_interest - current_interest,
                "balance_diff": new_end_balance - current_end_balance,
            },
        )
    return comparison

run_holding_period_analysis(current_balance, current_rate, current_remaining_years, new_rate, new_term_years, closing_costs, opportunity_rate, marginal_tax_rate, holding_periods, cash_out=0.0)

Run holding period analysis for various time frames.

Parameters:

Name Type Description Default
current_balance float

Current loan balance

required
current_rate float

Current loan interest rate (as a decimal)

required
current_remaining_years float

Current loan remaining term in years

required
new_rate float

New loan interest rate (as a decimal)

required
new_term_years float

New loan term in years

required
closing_costs float

Closing costs for refinance

required
opportunity_rate float

Opportunity cost rate (as a decimal)

required
marginal_tax_rate float

Marginal tax rate (as a decimal)

required
holding_periods list[int]

List of holding periods in years to analyze

required
cash_out float

Cash out amount from refinance. Default is 0.

0.0

Returns:

Type Description
list[dict]

List of dictionaries with analysis results for each holding period.

list[dict]

Each dictionary contains: - years: Holding period in years - nominal_savings: Nominal savings over holding period - npv: NPV of savings over holding period - npv_after_tax: NPV of savings after tax adjustment - recommendation: Recommendation string based on NPV

Source code in src/refi_calculator/core/calculations.py
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
def run_holding_period_analysis(
    current_balance: float,
    current_rate: float,
    current_remaining_years: float,
    new_rate: float,
    new_term_years: float,
    closing_costs: float,
    opportunity_rate: float,
    marginal_tax_rate: float,
    holding_periods: list[int],
    cash_out: float = 0.0,
) -> list[dict]:
    """Run holding period analysis for various time frames.

    Args:
        current_balance: Current loan balance
        current_rate: Current loan interest rate (as a decimal)
        current_remaining_years: Current loan remaining term in years
        new_rate: New loan interest rate (as a decimal)
        new_term_years: New loan term in years
        closing_costs: Closing costs for refinance
        opportunity_rate: Opportunity cost rate (as a decimal)
        marginal_tax_rate: Marginal tax rate (as a decimal)
        holding_periods: List of holding periods in years to analyze
        cash_out: Cash out amount from refinance. Default is 0.

    Returns:
        List of dictionaries with analysis results for each holding period.
        Each dictionary contains:
            - years: Holding period in years
            - nominal_savings: Nominal savings over holding period
            - npv: NPV of savings over holding period
            - npv_after_tax: NPV of savings after tax adjustment
            - recommendation: Recommendation string based on NPV
    """
    results = []
    current_loan = LoanParams(current_balance, current_rate, current_remaining_years)
    new_balance = current_balance + closing_costs + cash_out
    new_loan = LoanParams(new_balance, new_rate, new_term_years)

    monthly_savings = current_loan.monthly_payment - new_loan.monthly_payment
    monthly_opp_rate = opportunity_rate / 12

    current_avg_monthly_interest = current_loan.total_interest / current_loan.num_payments
    new_avg_monthly_interest = new_loan.total_interest / new_loan.num_payments
    current_monthly_tax_benefit = current_avg_monthly_interest * marginal_tax_rate
    new_monthly_tax_benefit = new_avg_monthly_interest * marginal_tax_rate
    after_tax_monthly_savings = (current_loan.monthly_payment - current_monthly_tax_benefit) - (
        new_loan.monthly_payment - new_monthly_tax_benefit
    )

    for years in holding_periods:
        months = years * 12
        nominal_savings = (monthly_savings * months) - closing_costs
        npv = -closing_costs
        for m in range(1, months + 1):
            npv += monthly_savings / ((1 + monthly_opp_rate) ** m)
        npv_after_tax = -closing_costs
        for m in range(1, months + 1):
            npv_after_tax += after_tax_monthly_savings / ((1 + monthly_opp_rate) ** m)

        strong_yes_threshold = 5000
        yes_threshold = 0
        marginal_threshold = -2000
        if npv > strong_yes_threshold:
            recommendation = "Strong Yes"
        elif npv > yes_threshold:
            recommendation = "Yes"
        elif npv > -marginal_threshold:
            recommendation = "Marginal"
        else:
            recommendation = "No"

        results.append(
            {
                "years": years,
                "nominal_savings": nominal_savings,
                "npv": npv,
                "npv_after_tax": npv_after_tax,
                "recommendation": recommendation,
            },
        )
    return results

run_sensitivity(current_balance, current_rate, current_remaining_years, new_term_years, closing_costs, opportunity_rate, rate_steps, npv_window_years=5)

Run sensitivity analysis over a range of new interest rates.

Parameters:

Name Type Description Default
current_balance float

Current loan balance

required
current_rate float

Current loan interest rate (as a decimal)

required
current_remaining_years float

Current loan remaining term in years

required
new_term_years float

New loan term in years

required
closing_costs float

Closing costs for refinance

required
opportunity_rate float

Opportunity cost rate (as a decimal)

required
rate_steps list[float]

List of new interest rates (as decimals) to analyze

required
npv_window_years int

Years to calculate NPV over. Default is 5 years.

5

Returns:

Type Description
list[dict]

List of dictionaries with sensitivity analysis results for each new rate.

list[dict]

Each dictionary contains: - new_rate: New interest rate (as a percentage) - monthly_savings: Monthly savings from refinancing - simple_be: Months to simple breakeven - npv_be: Months to NPV breakeven - five_yr_npv: NPV of savings over 5 years

Source code in src/refi_calculator/core/calculations.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
def run_sensitivity(
    current_balance: float,
    current_rate: float,
    current_remaining_years: float,
    new_term_years: float,
    closing_costs: float,
    opportunity_rate: float,
    rate_steps: list[float],
    npv_window_years: int = 5,
) -> list[dict]:
    """Run sensitivity analysis over a range of new interest rates.

    Args:
        current_balance: Current loan balance
        current_rate: Current loan interest rate (as a decimal)
        current_remaining_years: Current loan remaining term in years
        new_term_years: New loan term in years
        closing_costs: Closing costs for refinance
        opportunity_rate: Opportunity cost rate (as a decimal)
        rate_steps: List of new interest rates (as decimals) to analyze
        npv_window_years: Years to calculate NPV over. Default is 5 years.

    Returns:
        List of dictionaries with sensitivity analysis results for each new rate.
        Each dictionary contains:
            - new_rate: New interest rate (as a percentage)
            - monthly_savings: Monthly savings from refinancing
            - simple_be: Months to simple breakeven
            - npv_be: Months to NPV breakeven
            - five_yr_npv: NPV of savings over 5 years
    """
    results = []
    for new_rate in rate_steps:
        a = analyze_refinance(
            current_balance,
            current_rate,
            current_remaining_years,
            new_rate,
            new_term_years,
            closing_costs,
            opportunity_rate,
            npv_window_years=npv_window_years,
        )
        results.append(
            {
                "new_rate": new_rate * 100,
                "monthly_savings": a.monthly_savings,
                "simple_be": a.simple_breakeven_months,
                "npv_be": a.npv_breakeven_months,
                "five_yr_npv": a.five_year_npv,
            },
        )
    return results