Skip to content

Commit

Permalink
feature: extra principal payments (#2)
Browse files Browse the repository at this point in the history
Supports defining extra principal payments for loans analogously to events
  • Loading branch information
iwvelando committed Jul 12, 2020
1 parent 53e907a commit e591309
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 29 deletions.
32 changes: 16 additions & 16 deletions README.md
Expand Up @@ -36,29 +36,29 @@ finance-forecast -config=./config.yaml.example -output-format=csv
Output:

```
"date","amount (current path)","notes (current path)","amount (new home purchase)","notes (new home purchase)"
"2020-07","30000.00","","30000.00",""
"2020-08","29670.24","","29670.24",""
"2020-09","29340.47","","29340.47",""
"2020-10","29000.71","","30111.82",""
"date","amount (current path)","notes (current path)","amount (new home purchase)","notes (new home purchase)","amount (new home purchase with extra principal payments)","notes (new home purchase with extra principal payments)"
"2020-07","30000.00","","30000.00","","30000.00",""
"2020-08","29670.24","","29670.24","","29670.24",""
"2020-09","29340.47","","29340.47","","29340.47",""
...
"2031-04","22984.27","","129321.71",""
"2031-05","23085.46","","8730.89","paying off asset 5678 Street Address for 125301.49"
"2031-06","23186.66","","11041.56",""
"2031-07","23277.86","","13342.23",""
"2031-08","23379.06","","15652.90",""
"2030-10","22016.12","","122615.85","","83515.85",""
"2030-11","22117.31","","123673.33","","11167.85","paying off asset 5678 Street Address for 80658.67"
"2030-12","22218.51","","124730.82","","6278.52",""
"2031-01","22309.71","","125778.30","","8579.19",""
"2031-02","22410.91","","126835.79","","10889.86",""
"2031-03","22512.11","","127893.27","","13200.53",""
"2031-04","22603.31","","128940.75","","15501.20",""
"2031-05","22704.50","","8349.93","paying off asset 5678 Street Address for 125301.49","17811.87",""
"2031-06","22805.70","","10660.60","","20122.54",""
...
"2089-09","285469.91","","566730.97",""
"2089-10","286424.91","","567685.97",""
"2089-11","287389.91","","568650.97",""
"2089-12","282354.91","","562415.97",""
"2090-01","283309.91","","563370.97",""
"2089-11","286955.01","","568270.01","","577731.95",""
"2089-12","281920.01","","562035.01","","571496.95",""
"2090-01","282875.01","","562990.01","","572451.95",""
```

## Future Work

* Implement tests
* Support periodic extra principal payments on loans
* Ability to declare categories for events and tabulate average spending and income based on category
* Consider methods for simple optimization by configuring supported values as within a range and optimize a cost function
* Handle inflation
Expand Down
44 changes: 44 additions & 0 deletions config.yaml.example
Expand Up @@ -126,3 +126,47 @@ scenarios:
startDate: 2022-06
escrow: 600.00
earlyPayoffThreshold: 5000.00
- name: new home purchase with extra principal payments
active: true
events:
- name: Income
amount: 1234.56
frequency: 1
endDate: 2020-09
- name: Income (new job)
amount: 2345.67
frequency: 1
startDate: 2020-10
endDate: 2050-01
loans:
- name: 1234 Street Address
principal: 150000.00
downPayment: 10000.00
interestRate: 3.75
term: 360
startDate: 2018-01
escrow: 500.00
mortgageInsurance: 50.00
mortgageInsuranceCutoff: 78.00
earlyPayoffDate: 2022-06
sellProperty: true
valueChange: 2.00
- name: 5678 Street Address
principal: 200000.00
downPayment: 40000.00
interestRate: 2.75
term: 360
startDate: 2022-06
escrow: 600.00
earlyPayoffThreshold: 5000.00
# extraPrincipalPayments: you can configure one or more recurring or
# one-off extra principal payments; all the parameters from Events are
# supported; names are optional and just for the user's benefit.
extraPrincipalPayments:
- amount: 100.00
frequency: 1
- name: large one-off payment
amount: 29000.00
frequency: 1
startDate: 2023-06
endDate: 2023-06
26 changes: 26 additions & 0 deletions config/config.go
Expand Up @@ -5,6 +5,7 @@ package config
import (
"fmt"
"github.com/spf13/viper"
"math"
"time"
)

Expand Down Expand Up @@ -76,6 +77,15 @@ func (conf *Configuration) ParseDateLists() error {
return err
}
}
// Check for extra principal payments within loans.
for j, loan := range scenario.Loans {
for k := range loan.ExtraPrincipalPayments {
err := conf.Scenarios[i].Loans[j].ExtraPrincipalPayments[k].FormDateList(*conf)
if err != nil {
return err
}
}
}
}

// Next handle the parsing for the Common Events.
Expand All @@ -86,6 +96,16 @@ func (conf *Configuration) ParseDateLists() error {
}
}

// Check for extra principal payments for common loans.
for i, loan := range conf.Common.Loans {
for j := range loan.ExtraPrincipalPayments {
err := conf.Common.Loans[i].ExtraPrincipalPayments[j].FormDateList(*conf)
if err != nil {
return err
}
}
}

return nil
}

Expand Down Expand Up @@ -135,3 +155,9 @@ func (event *Event) FormDateList(conf Configuration) error {

return nil
}

// Round rounds a value to two decimals, i.e. to represent real currency. Used
// for making logical comparisons.
func Round(val float64) float64 {
return math.Round(val*100) / 100
}
94 changes: 81 additions & 13 deletions config/loans.go
Expand Up @@ -24,10 +24,11 @@ type Loan struct {
EarlyPayoffDate string
SellProperty bool
ValueChange float64
ExtraPrincipalPayments []Event
AmortizationSchedule map[string]Payment
}

// Payment holds the values for a given payment
// Payment holds the values for a given payment.
type Payment struct {
Payment float64
Principal float64
Expand Down Expand Up @@ -77,9 +78,13 @@ func (loan *Loan) GetAmortizationSchedule(logger *zap.Logger, conf Configuration
// Handle the first month individually. TODO consider using a ghost point
// to prevent having to treat this differently.
var firstPayment Payment
firstPayment.Payment = loanPayment + loan.Escrow + loan.DownPayment
extraPrincipal, err := loan.ExtraPrincipal(logger, loan.StartDate)
if err != nil {
return err
}
firstPayment.Payment = loanPayment + loan.Escrow + loan.DownPayment + extraPrincipal
firstPayment.Interest = (loan.Principal - loan.DownPayment) * loan.InterestRate / (100.0 * 12.0)
firstPayment.Principal = loanPayment - firstPayment.Interest
firstPayment.Principal = loanPayment - firstPayment.Interest + extraPrincipal
firstPayment.RemainingPrincipal = (loan.Principal - loan.DownPayment) - firstPayment.Principal
firstPayment.RefundableEscrow = loan.Escrow
loan.AmortizationSchedule[loan.StartDate] = firstPayment
Expand Down Expand Up @@ -142,16 +147,56 @@ func (loan *Loan) GetAmortizationSchedule(logger *zap.Logger, conf Configuration
}
break
} else {
currentPayment.Payment = loanPayment + loan.Escrow
// Check for extra principal
extraPrincipal, err := loan.ExtraPrincipal(logger, currentMonth)
if err != nil {
return err
}

currentPayment.Payment = loanPayment + loan.Escrow + extraPrincipal
currentPayment.Interest = loan.AmortizationSchedule[previousMonth].RemainingPrincipal * loan.InterestRate / (100.0 * 12.0)
currentPayment.Principal = loanPayment - currentPayment.Interest
if month == loan.Term {
currentPayment.Principal = loanPayment - currentPayment.Interest + extraPrincipal

// Ensure we do not overpay on extra principal
if Round(currentPayment.Principal-loan.AmortizationSchedule[previousMonth].RemainingPrincipal) < extraPrincipal &&
Round(currentPayment.Principal-loan.AmortizationSchedule[previousMonth].RemainingPrincipal) > 0 {
// We could pay off the loan by paying a portion, but not all of, the
// extra principal.
extraPrincipal = currentPayment.Principal - loan.AmortizationSchedule[previousMonth].RemainingPrincipal
currentPayment.Payment = loanPayment + loan.Escrow + extraPrincipal
currentPayment.Interest = loan.AmortizationSchedule[previousMonth].RemainingPrincipal * loan.InterestRate / (100.0 * 12.0)
logger.Debug(fmt.Sprintf("%s: adjusting extraPrincipal to %.2f to prevent overpayment for loan %s", currentMonth, extraPrincipal, loan.Name),
zap.String("op", "config.GetAmortizationSchedule"),
)
currentPayment.Principal = loanPayment - currentPayment.Interest + extraPrincipal
} else if Round(currentPayment.Principal-loan.AmortizationSchedule[previousMonth].RemainingPrincipal) > extraPrincipal {
// In this case we should not be paying any extra principal; the
// payment is actually liable to be reduced; adjust extraPrincipal to
// be the appropriate non-positive value to make this adjustment.
extraPrincipal = loan.AmortizationSchedule[previousMonth].RemainingPrincipal - (currentPayment.Principal - extraPrincipal)
currentPayment.Payment = loanPayment + loan.Escrow + extraPrincipal
currentPayment.Interest = loan.AmortizationSchedule[previousMonth].RemainingPrincipal * loan.InterestRate / (100.0 * 12.0)
currentPayment.Principal = loanPayment - currentPayment.Interest + extraPrincipal
logger.Debug(fmt.Sprintf("%s: adjusting extraPrincipal to %.2f to prevent overpayment for loan %s", currentMonth, extraPrincipal, loan.Name),
zap.String("op", "config.GetAmortizationSchedule"),
)
}

if month == loan.Term || Round(loan.AmortizationSchedule[previousMonth].RemainingPrincipal-currentPayment.Principal) == 0 {
// We will get machine error otherwise so just set to 0.
currentPayment.RemainingPrincipal = 0.00
// Incorporate the expected escrow refund; the RedunableEscrow value
// tracks the refundable amount for early payoffs so we need to reduce
// further by an escrow payment
currentPayment.Payment -= (currentPayment.RefundableEscrow + loan.Escrow)
december, err := CheckMonth(currentMonth, "12")
if err != nil {
return err
}
if !december {
// Incorporate the expected escrow refund; the RedunableEscrow value
// tracks the refundable amount for early payoffs so we need to
// reduce further by an escrow payment. Note that here we assume that
// if a loan matures naturally then escrow will be applied that year
// on december; this is not the assumption we use for early payoffs.
currentPayment.Payment -= currentPayment.RefundableEscrow + loan.Escrow
}
} else {
currentPayment.RemainingPrincipal = loan.AmortizationSchedule[previousMonth].RemainingPrincipal - currentPayment.Principal
}
Expand All @@ -163,7 +208,7 @@ func (loan *Loan) GetAmortizationSchedule(logger *zap.Logger, conf Configuration
loan.AmortizationSchedule[currentMonth] = currentPayment
// Since the loan matured we will extrapolate the escrow to be paid on
// Decembers.
if month == loan.Term {
if month == loan.Term || Round(loan.AmortizationSchedule[previousMonth].RemainingPrincipal-currentPayment.Principal) == 0 {
for {
if currentMonth == conf.Common.DeathDate {
break
Expand All @@ -172,17 +217,19 @@ func (loan *Loan) GetAmortizationSchedule(logger *zap.Logger, conf Configuration
if err != nil {
return err
}
if december {
if december && loan.Escrow > 0 && month != loan.Term {
var escrowPayment Payment
escrowPayment.Payment = loan.Escrow * 12
loan.AmortizationSchedule[currentMonth] = escrowPayment
}
previousMonth = currentMonth
currentMonth, err = OffsetDate(currentMonth, DateTimeLayout, 1)
month = 0
if err != nil {
return err
}
}
break
}
}
previousMonth = currentMonth
Expand All @@ -195,6 +242,27 @@ func (loan *Loan) GetAmortizationSchedule(logger *zap.Logger, conf Configuration
return nil
}

// ExtraPrincipal returns an extra principal payment, if present, or 0
func (loan *Loan) ExtraPrincipal(logger *zap.Logger, date string) (float64, error) {
amount := 0.00
dateT, err := time.Parse(DateTimeLayout, date)
if err != nil {
return amount, err
}
for _, event := range loan.ExtraPrincipalPayments {
for _, paymentDate := range event.DateList {
if dateT.Equal(paymentDate) {
logger.Debug(fmt.Sprintf("%s: applying extra principal payment %.2f for loan %s", date, event.Amount, loan.Name),
zap.String("op", "config.ExtraPrincipal"),
)
amount += event.Amount
break
}
}
}
return amount, nil
}

// CheckEarlyPayoffThreshold checks for whether or not it is time to payoff a
// loan early based on an optionally-configured threshold. Note that escrow
// refunds are not factored into the threshold comparison because in reality
Expand All @@ -211,7 +279,7 @@ func (loan *Loan) CheckEarlyPayoffThreshold(logger *zap.Logger, currentMonth str
if err != nil {
return note, err
}
if balance-loan.AmortizationSchedule[previousMonth].RemainingPrincipal >= loan.EarlyPayoffThreshold {
if Round(balance-loan.AmortizationSchedule[previousMonth].RemainingPrincipal) >= loan.EarlyPayoffThreshold {
logger.Debug(fmt.Sprintf("%s: based on threshold paying off asset %s for %.2f", currentMonth, loan.Name, loan.AmortizationSchedule[previousMonth].RemainingPrincipal),
zap.String("op", "config.CheckEarlyPayoffThreshold"),
)
Expand Down

0 comments on commit e591309

Please sign in to comment.