Overview

The purpose of this example is to illustrate how to customize update functions in RetirementPlanners.jl. The general process of creating a custom update function involves the following steps:

  1. Define a function with the following signature your_update_function(model::AbstractModel, t; kwargs...) where kwargs... is an optional set of keyword arguments.
  2. Pass the function to the configuration data structure config. For example update_investments! = your_update_function.
  3. Optionally pass keyword arguments to config, e.g., kw_investments = (kwargs...)

As an illustrative example, consider a person who is considering the cost of a financial advisor who charges an annual 1% fee of the value of the assets. Estimating the cost is not straightforward because the fee decreases future growth potential. To estimate the cost, we will run simulations of identical scenarios, except in one case there is a 1% fee, but in the other case there is no fee.

Load Packages

Below, we load the required packages.

using Plots
using Random
using RetirementPlanners

Custom Functions

We will define an custom update function for update_investments!, which requires model and time t as positional inputs, and fee_rate as a keyword argument. The custom function is the same as the default function for update_investments!, except the fee is applied once a year to the net_worth of investments.

function update_investments_fee!(model::AbstractModel, t; fee_rate = 0.0, _...)
    model.state.net_worth -= model.state.withdraw_amount
    model.state.net_worth += model.state.invest_amount
    if mod(t, 1) ≈ 0
        model.state.net_worth *= (1 - fee_rate)
    end
    real_growth = compute_real_growth_rate(model)
    model.state.net_worth *= (1 + real_growth)^model.Δt
    return nothing
end
update_investments_fee! (generic function with 1 method)

In most cases, it is sufficient to define the new update function. However, in this particular case, it is necessary to develop a custom simulate! function which runs each repetition with a different seed for the random number generator. This allows us to generate random simulations which only differ by the fee. In other words, the simulations are correlated between the different conditions. If we did not equate the seeds between the two conditions, the cost would be negative in some cases because we would be comparing simulations under different conditions (e.g., the random growth rate might differ).

import RetirementPlanners: simulate!
using RetirementPlanners: simulate_once!
using RetirementPlanners: compute_real_growth_rate
function simulate!(model::AbstractModel, logger::AbstractLogger, n_reps, seed)
    for rep ∈ 1:n_reps
        Random.seed!(seed + rep)
        simulate_once!(model, logger, rep)
    end
    return nothing
end
simulate! (generic function with 2 methods)

Configuration

We will define a configuration for both conditions. The first configuration corresponds to the advisor fee condition. The second configuration is identical except the fee is eliminated.

config = (
    # time step in years
    Δt = 1 / 12,
    # start age of simulation
    start_age = 50,
    # duration of simulation in years
    duration = 40,
    # initial investment amount
    start_amount = 1_000_000,
    update_investments! = update_investments_fee!,
    # investment parameters
    kw_investments = (; fee_rate = 0.01),
    # withdraw parameters
    kw_withdraw = (;
        withdraws = Transaction(; start_age = 55, amount = Normal(2000, 200)),),
    # invest parameters
    kw_invest = (investments = Transaction(; start_age = 0, end_age = 0, amount = 0.0),),
    # interest parameters
    kw_market = (; gbm = VarGBM(; αμ = 0.080, ημ = 0.010, ασ = 0.035, ησ = 0.010),),
    # inflation parameters
    kw_inflation = (gbm = VarGBM(; αμ = 0.035, ημ = 0.005, ασ = 0.005, ησ = 0.0025),),
    # income parameters
    kw_income = (income_sources = Transaction(; start_age = 67, amount = 2000.0),)
)
# setup retirement model
model = Model(; config...)
Model
┌─────────────────────┬─────────────────────────────────────────────────────────
│ Field               │ Value                                                  ⋯
├─────────────────────┼─────────────────────────────────────────────────────────
│ Δt                  │  0.08                                                  ⋯
│ duration            │ 40.00                                                  ⋯
│ start_age           │ 50.00                                                  ⋯
│ start_amount        │ 1000000.00                                             ⋯
│ log_times           │ 50.083333333333336:0.08333333333333333:90.0            ⋯
│ state               │ State{Float64}(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1)        ⋯
│ withdraw!           │ withdraw!                                              ⋯
│ invest!             │ invest!                                                ⋯
│ update_income!      │ update_income!                                         ⋯
│ update_inflation!   │ dynamic_inflation                                      ⋯
│ update_market!      │ dynamic_market                                         ⋯
│ update_investments! │ update_investments_fee!                                ⋯
│ log!                │ default_log!                                           ⋯
│ config              │ (kw_investments = (fee_rate = 0.01,), kw_withdraw = (w ⋯
└─────────────────────┴─────────────────────────────────────────────────────────
                                                                1 column omitted
config2 = (
    # time step in years
    Δt = 1 / 12,
    # start age of simulation
    start_age = 50,
    # duration of simulation in years
    duration = 40,
    # initial investment amount
    start_amount = 1_000_000,
    update_investments! = update_investments_fee!,
    # investment parameters
    kw_investments = (; fee_rate = 0.00),
    # withdraw parameters
    kw_withdraw = (;
        withdraws = Transaction(; start_age = 55, amount = Normal(2000, 200)),),
    # invest parameters
    kw_invest = (investments = Transaction(; start_age = 0, end_age = 0, amount = 0.0),),
    # interest parameters
    kw_market = (; gbm = VarGBM(; αμ = 0.080, ημ = 0.010, ασ = 0.035, ησ = 0.010),),
    # inflation parameters
    kw_inflation = (gbm = VarGBM(; αμ = 0.035, ημ = 0.005, ασ = 0.005, ησ = 0.0025),),
    # income parameters
    kw_income = (income_sources = Transaction(; start_age = 67, amount = 2000.0),)
)
# setup retirement model
model2 = Model(; config2...)
Model
┌─────────────────────┬─────────────────────────────────────────────────────────
│ Field               │ Value                                                  ⋯
├─────────────────────┼─────────────────────────────────────────────────────────
│ Δt                  │  0.08                                                  ⋯
│ duration            │ 40.00                                                  ⋯
│ start_age           │ 50.00                                                  ⋯
│ start_amount        │ 1000000.00                                             ⋯
│ log_times           │ 50.083333333333336:0.08333333333333333:90.0            ⋯
│ state               │ State{Float64}(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1)        ⋯
│ withdraw!           │ withdraw!                                              ⋯
│ invest!             │ invest!                                                ⋯
│ update_income!      │ update_income!                                         ⋯
│ update_inflation!   │ dynamic_inflation                                      ⋯
│ update_market!      │ dynamic_market                                         ⋯
│ update_investments! │ update_investments_fee!                                ⋯
│ log!                │ default_log!                                           ⋯
│ config              │ (kw_investments = (fee_rate = 0.0,), kw_withdraw = (wi ⋯
└─────────────────────┴─────────────────────────────────────────────────────────
                                                                1 column omitted

Run Simulations

In the code block below, we will run both simulations 1000 times each.

seed = 8564
times = get_times(model)
n_reps = 1000
n_steps = length(times)
logger1 = Logger(; n_steps, n_reps)
logger2 = Logger(; n_steps, n_reps)

# simulate scenario with fee
simulate!(model, logger1, n_reps, seed)

# simulate scenario without fee
simulate!(model2, logger2, n_reps, seed)

The code block below computes the cost as the difference in investment value between the simulations with an advisor fee and the simulations without the advisor fee. The units are expressed in millions of dollars for ease of interpretation.

net_worth_diff = (logger2.net_worth .- logger1.net_worth) / 1_000_000
480×1000 Matrix{Float64}:
 0.0      0.0      0.0      0.0       0.0      …  0.0      0.0      0.0
 0.0      0.0      0.0      0.0       0.0         0.0      0.0      0.0
 0.0      0.0      0.0      0.0       0.0         0.0      0.0      0.0
 0.0      0.0      0.0      0.0       0.0         0.0      0.0      0.0
 0.0      0.0      0.0      0.0       0.0         0.0      0.0      0.0
 0.0      0.0      0.0      0.0       0.0      …  0.0      0.0      0.0
 0.0      0.0      0.0      0.0       0.0         0.0      0.0      0.0
 0.0      0.0      0.0      0.0       0.0         0.0      0.0      0.0
 0.0      0.0      0.0      0.0       0.0         0.0      0.0      0.0
 0.0      0.0      0.0      0.0       0.0         0.0      0.0      0.0
 ⋮                                             ⋱                    
 1.25046  1.39089  1.09447  0.709746  3.24595     1.64924  1.97433  0.635295
 1.26336  1.39325  1.10879  0.71349   3.2125      1.64549  1.99378  0.640352
 1.29199  1.40353  1.123    0.71724   3.17463     1.65308  2.01014  0.642064
 1.30638  1.40968  1.11393  0.729441  3.16108     1.66822  2.03034  0.645841
 1.32086  1.41171  1.1283   0.733126  3.18488  …  1.65892  2.05415  0.644411
 1.29021  1.40372  1.128    0.742763  3.22529     1.67237  2.05504  0.645181
 1.31123  1.42007  1.13715  0.750082  3.21727     1.68905  2.06861  0.642919
 1.30055  1.42974  1.11091  0.758419  3.20156     1.70879  2.09321  0.653705
 1.35924  1.44121  1.11761  0.781706  3.27117     1.7397   2.1594   0.649018

Plot Results

Histograms of the cost are panneled at 10, 20, 30, and 40 years. As expected, the cost increases across time and become increasingly variable. Importantly, the cost is quite large. After 20 years the interquartile range is .32 - .48 million, and increases to .60 - 1.10 million after 30 years.

Show Code
idx = 12 * 10
p10 = histogram(
    net_worth_diff[idx, :],
    norm = true,
    leg = false,
    grid = false,
    title = "10 years"
)

idx = 12 * 20
p20 = histogram(
    net_worth_diff[idx, :],
    norm = true,
    leg = false,
    grid = false,
    title = "20 years"
)

idx = 12 * 30
p30 = histogram(
    net_worth_diff[idx, :],
    norm = true,
    leg = false,
    grid = false,
    title = "30 years"
)

idx = 12 * 40
p40 = histogram(
    net_worth_diff[idx, :],
    norm = true,
    leg = false,
    grid = false,
    title = "40 years"
)
Example block output
plot(p10, p20, p30, p40, layout = (2, 2), xlabel = "Cost in millions")
Example block output