8 from type.money import Money
10 from person import Person
11 import simulation_params as sp
12 import simulation_results as sr
16 logger = logging.getLogger(__name__)
19 class Verbosity(enum.IntEnum):
25 class ReportColorizer(ansi.ProgrammableColorizer):
29 (re.compile('^(.+)\:$', re.MULTILINE), self.header),
30 (re.compile(' *([A-Z]+[A-Z ]+)\: *'), self.title)
34 def header(self, match: re.match):
35 return f'{ansi.fg("chateau green")}{match.group()}{ansi.reset()}'
37 def title(self, match: re.match):
40 return f'{ansi.fg("mint green")}{ansi.bold()}{ansi.underline()}{s}{ansi.reset()}'
44 class Simulation(object):
47 params: sp.SimulationParameters,
53 self.accounts = [x for x in params.initial_account_states]
54 self.taxes = taxman.TaxCollector(
55 params.federal_ordinary_income_tax_brackets,
56 params.federal_dividends_and_long_term_gains_brackets
59 self.returns_and_expenses = params.returns_and_expenses
61 def do_rmd_withdrawals(self) -> Money:
62 """Determine if any account that has RMDs will require someone to
63 take money out of it this year and, if so, how much. Then do
66 for x in self.accounts:
67 if x.has_rmd() and x.get_owner() == Person.SCOTT:
68 rmd = x.do_rmd_withdrawal(self.scott_age, self.taxes)
69 total_withdrawn += rmd
70 elif x.has_rmd() and (x.get_owner() == Person.LYNN or x.get_owner() == Person.SHARED):
71 rmd = x.do_rmd_withdrawal(self.lynn_age, self.taxes)
72 total_withdrawn += rmd
73 return total_withdrawn
75 def go_find_money(self, amount: Money):
76 """Look through accounts and try to find amount using some heuristics
77 about where to withdraw money first."""
78 logger.debug(f'Simulation: looking for {amount}...')
80 # Try brokerage accounts first
81 for x in self.accounts:
82 if not x.is_age_restricted():
83 if x.get_balance() >= amount:
85 f'Simulation: withdrawing {amount} from {x.get_name()}'
87 x.withdraw(amount, self.taxes)
90 elif x.get_balance() > 0.01 and x.get_balance() < amount:
91 money_available = x.get_balance()
93 f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
94 '(and exhausting the account)'
96 x.withdraw(money_available, self.taxes)
97 amount -= money_available
99 # Next try age restircted accounts
100 for x in self.accounts:
102 x.is_age_restricted() and
104 x.belongs_to_lynn() and
107 if x.get_balance() >= amount:
109 f'Simulation: withdrawing {amount} from {x.get_name()}'
111 x.withdraw(amount, self.taxes)
114 elif x.get_balance() > 0.01 and x.get_balance() < amount:
115 money_available = x.get_balance()
117 f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
118 '(and exhausting the account)'
120 x.withdraw(money_available, self.taxes)
121 amount -= money_available
124 x.is_age_restricted() and
126 x.belongs_to_scott() and
129 if x.get_balance() >= amount:
131 f'Simulation: withdrawing {amount} from {x.get_name()}'
133 x.withdraw(amount, self.taxes)
136 elif x.get_balance() > 0.01 and x.get_balance() < amount:
137 money_available = x.get_balance()
139 f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
140 '(and exhausting the account)'
142 x.withdraw(money_available, self.taxes)
143 amount -= money_available
145 # Last try Roth accounts
146 for x in self.accounts:
148 x.is_age_restricted() and
150 x.belongs_to_lynn() and
153 if x.get_balance() >= amount:
155 f'Simulation: withdrawing {amount} from {x.get_name()}'
157 x.withdraw(amount, self.taxes)
160 elif x.get_balance() > 0.01 and x.get_balance() < amount:
161 money_available = x.get_balance()
163 f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
164 '(and exhausting the account)'
166 x.withdraw(money_available, self.taxes)
167 amount -= money_available
170 x.is_age_restricted() and
172 x.belongs_to_scott() and
175 if x.get_balance() >= amount:
177 f'Simulation: withdrawing {amount} from {x.get_name()}'
179 x.withdraw(amount, self.taxes)
182 elif x.get_balance() > 0.01 and x.get_balance() < amount:
183 money_available = x.get_balance()
185 f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
186 '(and exhausting the account)'
188 x.withdraw(money_available, self.taxes)
189 amount -= money_available
190 raise Exception("Unable to find enough money this year!")
192 def get_social_security(self) -> Money:
193 total_benefit = Money(0)
194 if self.scott_age >= self.params.scott_social_security_age:
195 total_benefit += self.params.scott_annual_social_security_dollars
196 self.taxes.record_ordinary_income(
197 self.params.scott_annual_social_security_dollars
199 if self.lynn_age >= self.params.lynn_social_security_age:
200 total_benefit += self.params.lynn_annual_social_security_dollars
201 self.taxes.record_ordinary_income(
202 self.params.lynn_annual_social_security_dollars
206 def do_opportunistic_roth_conversions(self) -> Money:
207 desired_conversion_amount = (
208 self.taxes.how_many_more_dollars_can_we_earn_without_changing_tax_rate()
210 money_converted = Money(0)
211 for x in self.accounts:
213 amount = x.do_roth_conversion(desired_conversion_amount)
214 money_converted += amount
215 desired_conversion_amount -= amount
216 return money_converted
220 silent: Verbosity = Verbosity.NORMAL
221 ) -> sr.SimulationResults:
222 if silent is not Verbosity.SILENT:
223 with ReportColorizer():
226 results = sr.SimulationResults.create()
228 for year in range(2022, 2069):
230 self.scott_age = year - 1974
231 self.lynn_age = year - 1964
232 self.alex_age = year - 2005
234 # Computed money needed this year based on inflated budget.
235 money_needed = self.params.annual_expenses
237 # When Alex is in college, we need $60K more per year.
238 if self.alex_age > 18 and self.alex_age <= 22:
239 money_needed += Money(60000)
241 if silent is Verbosity.VERBOSE:
243 f'\nYear: {year}, estimated annual expenses {money_needed} -----------------\n'
246 f'Scott is {self.scott_age}, Lynn is {self.lynn_age} and Alex is {self.alex_age}'
249 # Print out how much money is in each account +
252 if silent is not Verbosity.VERBOSE:
253 for x in self.accounts:
254 total += x.get_balance()
256 for x in self.accounts:
257 total += x.get_balance()
258 print(' %-50s: %18s' % (x.get_name(), x.get_balance()))
259 print(" %-50s: %18s" % ("TOTAL NET WORTH", total))
262 if total > results.max_networth:
263 results.max_networth = total
264 results.max_networth_year = year
266 # Now, figure out how to find money to pay for this year
267 # and how much of it is taxable.
270 # When we reach a certain age we have to take RMDs from
271 # some accounts. Handle that here.
272 rmds = self.do_rmd_withdrawals()
275 f'Simulation: satisfied {rmds} of RMDs from age-restricted accounts.'
279 if rmds > results.max_rmd:
280 results.max_rmd = rmds
281 results.max_rmd_year = year
283 # When we reach a certain age we are eligible for SS
285 ss = self.get_social_security()
286 results.total_social_security += ss
289 f'Simulation: received {ss} from social security.'
294 # If we still need money, try to go find it in accounts.
296 self.go_find_money(money_needed)
297 total_income += money_needed
300 # Maybe do some opportunistic Roth conversions.
301 taxes_due = self.taxes.approximate_annual_taxes()
302 old_taxes_due = taxes_due
303 tax_rate = self.taxes.approximate_annual_tax_rate()
304 if tax_rate <= 0.16 and year <= 2035:
305 rollover = self.do_opportunistic_roth_conversions()
306 if rollover > results.max_rollover:
307 results.max_rollover = rollover
308 results.max_rollover_year = year
309 results.total_rollover += rollover
311 # These conversions will affect taxes due.
312 # Recompute them now.
314 taxes_due = self.taxes.approximate_annual_taxes()
315 tax_rate = self.taxes.approximate_annual_tax_rate()
317 f'Simulation: rolled {rollover} from pretax into Roth which increased '
318 f'taxes by {taxes_due - old_taxes_due}'
321 # Pay taxes_due by going to find more money. This is
322 # a bit hacky since withdrawing more money to cover
323 # taxes will, in turn, cause taxable income. But I
324 # think taxes are low enough and this simulation is
325 # rough enough that we can ignore this.
326 if taxes_due > results.max_taxes:
327 results.max_taxes = taxes_due
328 results.max_taxes_year = year
331 f'Simulation: estimated {taxes_due} federal tax liability (tax rate={tax_rate})'
333 self.go_find_money(taxes_due)
335 logger.debug('Simulation: estimating no tax burden this year')
336 self.taxes.record_taxes_paid_and_reset_for_next_year(year)
338 # Inflation and appreciation. Note also: SS benefits
339 # currently are increased annually to adjust for
340 # inflation. Do that here too.
341 inflation = self.returns_and_expenses.get_annual_inflation_rate(year)
342 roi = self.returns_and_expenses.get_annual_investment_return_rate(year)
343 ss = self.returns_and_expenses.get_annual_social_security_increase_rate(year)
344 self.params.annual_expenses *= inflation
346 for x in self.accounts:
347 total += x.appreciate(roi)
348 if self.scott_age >= self.params.scott_social_security_age:
349 self.params.scott_annual_social_security_dollars *= ss
350 if self.lynn_age >= self.params.lynn_social_security_age:
351 self.params.lynn_annual_social_security_dollars *= ss
354 f'inflation={inflation.__repr__(relative=True)}, ' +
355 f'ROI={roi.__repr__(relative=True)}, ' +
359 if silent is not Verbosity.SILENT:
362 except Exception as e:
363 if silent is not Verbosity.SILENT:
365 print(f'**** Exception, out of money in {year}? ****')
366 results.success = False
368 results.success = True
371 results.end_year = self.year + 1
372 results.final_networth = Money(0)
374 for x in self.accounts:
375 results.final_networth += x.get_balance()
376 results.final_taxes = self.taxes
377 results.final_account_state = self.accounts
378 if silent is not Verbosity.SILENT:
379 with ReportColorizer():