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:
- Define a function with the following signature
your_update_function(model::AbstractModel, t; kwargs...)wherekwargs...is an optional set of keyword arguments. - Pass the function to the configuration data structure
config. For exampleupdate_investments! = your_update_function. - 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.
A full version of the code can be found by expanding the information below:
Show Code
using Distributions
using Plots
using Random
using RetirementPlanners
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
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
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.10, ημ = 0.02, ασ = 0.14, ησ = 0.020),),
# 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 scenario with fee
model_fee = Model(; config...)
# setup scenario without fee
model_no_fee = Model(; config..., kw_investments = (; fee_rate = 0.00))
seed = 8564
times = get_times(model_fee)
n_reps = 1000
n_steps = length(times)
logger_fee = Logger(; n_steps, n_reps)
logger_no_fee = Logger(; n_steps, n_reps)
# simulate scenario with fee
simulate!(model_fee, logger_fee, n_reps, seed)
# simulate scenario without fee
simulate!(model_no_fee, logger_no_fee, n_reps, seed)
net_worth_diff = (logger_no_fee.net_worth .- logger_fee.net_worth) / 1_000_000
hist_config = (
norm = true,
leg = false,
grid = false,
xlims = (0, 7),
)
p10 = histogram(
net_worth_diff[12 * 10, :],
title = "10 years";
hist_config...
)
p20 = histogram(
net_worth_diff[12 * 20, :],
title = "20 years";
hist_config...
)
p30 = histogram(
net_worth_diff[12 * 30, :],
title = "30 years";
hist_config...
)
p40 = histogram(
net_worth_diff[12 * 40, :],
title = "40 years";
hist_config...
)
plot(p10, p20, p30, p40, layout = (2, 2), xlabel = "Cost in millions")Load Packages
Below, we load the required packages.
using Distributions
using Plots
using Random
using RetirementPlannersCustom 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
endupdate_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_ratefunction 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
endsimulate! (generic function with 2 methods)Configuration
We will define a baseline configuration in which the advisor fee is set to .01. This will serve as the advisor fee condition.
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.10, ημ = 0.02, ασ = 0.14, ησ = 0.020),),
# 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_fee = Model(; config...) Model
┌─────────────────────┬─────────────────────────────────────────────────────────
│ Parameter │ Value ⋯
├─────────────────────┼─────────────────────────────────────────────────────────
│ Δt │ 0.08333333333333333 ⋯
│ duration │ 40.0 ⋯
│ start_age │ 50.0 ⋯
│ start_amount │ 1.0e6 ⋯
│ 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
The configuration for the no advisor fee condition is identitical except we also pass kw_investments = (; fee_rate = 0.00) to overwrite the default fee of .01.
# setup retirement model
model_no_fee = Model(; config..., kw_investments = (; fee_rate = 0.00)) Model
┌─────────────────────┬─────────────────────────────────────────────────────────
│ Parameter │ Value ⋯
├─────────────────────┼─────────────────────────────────────────────────────────
│ Δt │ 0.08333333333333333 ⋯
│ duration │ 40.0 ⋯
│ start_age │ 50.0 ⋯
│ start_amount │ 1.0e6 ⋯
│ 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_fee)
n_reps = 1000
n_steps = length(times)
logger_fee = Logger(; n_steps, n_reps)
logger_no_fee = Logger(; n_steps, n_reps)
# simulate scenario with fee
simulate!(model_fee, logger_fee, n_reps, seed)
# simulate scenario without fee
simulate!(model_no_fee, logger_no_fee, 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 = (logger_no_fee.net_worth .- logger_fee.net_worth) / 1_000_000480×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
⋮ ⋱
0.515523 3.58857 1.05537 0.518926 0.621997 2.53934 2.67463 0.0
0.530734 3.58739 1.10061 0.523338 0.636243 2.47827 2.76367 0.0
0.56741 3.62856 1.14791 0.529697 0.647363 2.50245 2.8321 0.0
0.580397 3.69394 1.10131 0.557145 0.649543 2.57183 2.93158 0.0
0.597759 3.70953 1.138 0.561773 … 0.639666 2.47846 3.05551 0.0
0.552755 3.53823 1.12785 0.584377 0.645476 2.5457 3.01742 0.0
0.578533 3.70817 1.16494 0.599273 0.634436 2.62498 3.06936 0.0
0.555777 3.77134 1.05167 0.620924 0.655656 2.7325 3.18804 0.0
0.610126 3.58993 1.03719 0.667436 0.651196 2.77197 3.42732 0.0Plot 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 .11 - .22 million, and increases to .522 - 2.16 million after 30 years.
Show Code
net_worth_diff = (logger_no_fee.net_worth .- logger_fee.net_worth) / 1_000_000
hist_config = (
norm = true,
leg = false,
grid = false,
xlims = (0, 7),
)
p10 = histogram(
net_worth_diff[12 * 10, :],
title = "10 years";
hist_config...
)
p20 = histogram(
net_worth_diff[12 * 20, :],
title = "20 years";
hist_config...
)
p30 = histogram(
net_worth_diff[12 * 30, :],
title = "30 years";
hist_config...
)
p40 = histogram(
net_worth_diff[12 * 40, :],
title = "40 years";
hist_config...
)plot(p10, p20, p30, p40, layout = (2, 2), xlabel = "Cost in millions")