SciML to solve compound interest equation

I just went through the SciML tutorials and now am trying to apply them by building a calculator for solving the compound interest equation. I have two working implementations, but neither seems very convenient. What is the best way to implement this model so that I can plug in all but one variable and solve for whichever one is missing?

F = P(1+\frac{r}{n})^{nt}+A\frac{(1+\frac{r}{n})^{nt}-1}{\frac{r}{n}}

First Try

I initially used just NonlinearSolve.jl directly. I wrote a function that steps sequentially through time. I then wrote a wrapper function to isolate the variable I wanted to solve for. Then I constructed an IntervalNonlinearProblem and solved.

The issues here are that …

  1. The results overshot in whole year steps because of the steps in my for loop.
  2. I have to write a new wrapper function and IntervalNonlinearProblem for every variable. (Not implemented.)
Code for First Implementation
using NonlinearSolve

"""
    finalvalue(; P=0, M=0, A=0, r=8, t=1) -> F

Calculate the final value of an investment assuming interest is compounded monthly.

# Arguments
- `F`: final value (after `t` years)
- `P`: principal value (before interest)
- `M`: regular monthly contibution
- `A`: regular annual contibution
- `r`: annual interest rate (%)
- `t`: total number of years
"""
function finalvalue(; P=0, M=0, A=0, r=8, t=1)
    r /= 100               # convert percent to decimal
    F = P                  # starting value
    for _ in 1:t
        for _ in 1:12
            F *= 1 + r/12  # apply monthly interest
            F += M         # add monthly contribution
        end
        F += A             # add annual contribution
    end
    return round(F, digits=2)
end

function findt(t, p)
    P = p[1]
    M = p[2]
    A = p[3]
    r = p[4]
    F = p[5]
    return finalvalue(; P, M, A, r, t) - F
end

p = [5000, 100, 0, 8, 14900]
tspan = (0.0, 10.0)
prob = IntervalNonlinearProblem(findt, tspan, p)
t = round(solve(prob).u, digits=2)

Second Try

Next I found and modeled the equation with ModelingToolkit.This resulted in better accuracy and smaller code. However,

  1. The only type that seemed applicable is a NonlinearSystem, so I have to wrap everything in vectors.
  2. I could not figure out how to create an IntervalNonlinearProblem from a NonlinearSystem, so I am using a NonlinearProblem instead. This seems like the wrong type of problem based on what I read in the docs.
  3. I don’t know how to switch which variables are @variables and which are @parameters without copying and pasting the entire code over and over and tweaking it. I know of the remake function, but that seems to only be able to change values not variables.
Code for Second Implementation
using ModelingToolkit, NonlinearSolve

@variables t
@parameters F P A r n

eq = [F ~ P * (1 + r/100/n)^(n*t) +
          A * ((1 + r/100/n)^(n*t) - 1) / (r/100/n)]

@named ns = NonlinearSystem(eq, [t], [F, P, A, r, n])
prob = NonlinearProblem(ns, [1], [F=>15000, P=>5000, A=>100, r=>8, n=>12])
sol = round(only(solve(prob).u), digits=2)

I have now implemented a function to choose which variable to solve for. (Apparently @variables can be known or unknown, so now I’m not sure what @parameters are.) It works fairly well for this simple problem, but I doubt it is an efficient/intended solution. Still wondering if there is a way to avoid creating a new NonlinearSystem every time and if there is a way to formulate this into an IntervalNonlinearProblem instead.

using ModelingToolkit, NonlinearSolve, Chain

@variables F P A r n t

eq = F ~ P * (1 + r/100/n)^(n*t) +
         A * ((1 + r/100/n)^(n*t) - 1) / (r/100/n)

function eqsolve(
        eq::Equation,
        guess::Pair{Num, <:Real},
        params::Dict{Num, <:Real},
    )
    @named ns = NonlinearSystem([eq], [guess.first], keys(params))
    prob = NonlinearProblem(ns, [guess.second], params)
    sol = @chain prob begin
        solve
        getfield(:u)
        only
        round(digits=2)
    end
    return guess.first => sol
end

guess1 = t => 1

params1 = Dict(
    F => 15000,
    P => 5000,
    A => 100,
    r => 8,
    n => 12,
)

guess2 = r => 1

params2 = Dict(
    F => 15000,
    P => 5000,
    A => 100,
    t => 5,
    n => 12,
)
julia> eqsolve(eq, guess1, params1)
t => 5.09

julia> eqsolve(eq, guess2, params2)
r => 8.36

That is a pretty cool solution. Honestly I would have coded up different functions and done some sort of dispatch to the correct one. Finding F, P, and A are easy for example. Probably t as well.

1 Like

For problems like this where I am coding up a book equation, I try to avoid manipulating or retyping the equation in multiple places because I am likely to introduce bugs that way. This is more of a proof of concept anyway to try to get a method under my belt for nonlinear equation solving. I would like to use Julia to replace some of our large Excel lookup tables.

I was hoping someone from SciML could tell me if I am on the right track (and clarify my confusion about the macros and Problem types).

I have now turned my compound interest calculator into a Pluto notebook, where the reactivity makes it really easy to explore input changes. The novelty is that you can quickly solve for any variable in the equation. Most online calculators I found will only solve for F.

You will probably want to download and run it locally because I cannot get it to precompile on Binder. :face_with_diagonal_mouth:

I’m still brand new to SciML, so tips are welcome. Enjoy!

1 Like