Updated.
[retire.git] / simulation.py
1 #!/usr/bin/env python3
2
3 import enum
4 import logging
5 import re
6
7 import ansi
8 from type.money import Money
9
10 from person import Person
11 import simulation_params as sp
12 import simulation_results as sr
13 import taxman
14
15
16 logger = logging.getLogger(__name__)
17
18
19 class Verbosity(enum.IntEnum):
20     SILENT = 0
21     NORMAL = 1
22     VERBOSE = 2
23
24
25 class ReportColorizer(ansi.ProgrammableColorizer):
26     def __init__(self):
27         super().__init__(
28             [
29                 (re.compile('^(.+)\:$', re.MULTILINE), self.header),
30                 (re.compile(' *([A-Z]+[A-Z ]+)\: *'), self.title)
31             ]
32         )
33
34     def header(self, match: re.match):
35         return f'{ansi.fg("chateau green")}{match.group()}{ansi.reset()}'
36
37     def title(self, match: re.match):
38         s = match.group()
39         if len(s) > 14:
40             return f'{ansi.fg("mint green")}{ansi.bold()}{ansi.underline()}{s}{ansi.reset()}'
41         return s
42
43
44 class Simulation(object):
45     def __init__(
46             self,
47             params: sp.SimulationParameters,
48     ):
49         self.year = 0
50         self.scott_age = 0
51         self.lynn_age = 0
52         self.alex_age = 0
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
57         )
58         self.params = params
59         self.returns_and_expenses = params.returns_and_expenses
60
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
64            the withdrawal."""
65         total_withdrawn = 0
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
74
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}...')
79
80         # Try brokerage accounts first
81         for x in self.accounts:
82             if not x.is_age_restricted():
83                 if x.get_balance() >= amount:
84                     logger.debug(
85                         f'Simulation: withdrawing {amount} from {x.get_name()}'
86                     )
87                     x.withdraw(amount, self.taxes)
88                     return
89
90                 elif x.get_balance() > 0.01 and x.get_balance() < amount:
91                     money_available = x.get_balance()
92                     logger.debug(
93                         f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
94                         '(and exhausting the account)'
95                     )
96                     x.withdraw(money_available, self.taxes)
97                     amount -= money_available
98
99         # Next try age restircted accounts
100         for x in self.accounts:
101             if (
102                     x.is_age_restricted() and
103                     x.has_rmd() and
104                     x.belongs_to_lynn() and
105                     self.lynn_age > 60
106             ):
107                 if x.get_balance() >= amount:
108                     logging.debug(
109                         f'Simulation: withdrawing {amount} from {x.get_name()}'
110                     )
111                     x.withdraw(amount, self.taxes)
112                     return
113
114                 elif x.get_balance() > 0.01 and x.get_balance() < amount:
115                     money_available = x.get_balance()
116                     logger.debug(
117                         f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
118                         '(and exhausting the account)'
119                     )
120                     x.withdraw(money_available, self.taxes)
121                     amount -= money_available
122
123             if (
124                     x.is_age_restricted() and
125                     x.has_rmd() and
126                     x.belongs_to_scott() and
127                     self.scott_age > 60
128             ):
129                 if x.get_balance() >= amount:
130                     logger.debug(
131                         f'Simulation: withdrawing {amount} from {x.get_name()}'
132                     )
133                     x.withdraw(amount, self.taxes)
134                     return
135
136                 elif x.get_balance() > 0.01 and x.get_balance() < amount:
137                     money_available = x.get_balance()
138                     logger.debug(
139                         f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
140                         '(and exhausting the account)'
141                     )
142                     x.withdraw(money_available, self.taxes)
143                     amount -= money_available
144
145         # Last try Roth accounts
146         for x in self.accounts:
147             if (
148                     x.is_age_restricted() and
149                     x.has_roth() and
150                     x.belongs_to_lynn() and
151                     self.lynn_age > 60
152             ):
153                 if x.get_balance() >= amount:
154                     logger.debug(
155                         f'Simulation: withdrawing {amount} from {x.get_name()}'
156                     )
157                     x.withdraw(amount, self.taxes)
158                     return
159
160                 elif x.get_balance() > 0.01 and x.get_balance() < amount:
161                     money_available = x.get_balance()
162                     logger.debug(
163                         f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
164                         '(and exhausting the account)'
165                     )
166                     x.withdraw(money_available, self.taxes)
167                     amount -= money_available
168
169             if (
170                     x.is_age_restricted() and
171                     x.has_roth() and
172                     x.belongs_to_scott() and
173                     self.scott_age > 60
174             ):
175                 if x.get_balance() >= amount:
176                     logger.debug(
177                         f'Simulation: withdrawing {amount} from {x.get_name()}'
178                     )
179                     x.withdraw(amount, self.taxes)
180                     return
181
182                 elif x.get_balance() > 0.01 and x.get_balance() < amount:
183                     money_available = x.get_balance()
184                     logger.debug(
185                         f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
186                         '(and exhausting the account)'
187                     )
188                     x.withdraw(money_available, self.taxes)
189                     amount -= money_available
190         raise Exception("Unable to find enough money this year!")
191
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
198             )
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
203             )
204         return total_benefit
205
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()
209         )
210         money_converted = Money(0)
211         for x in self.accounts:
212             if x.has_roth():
213                 amount = x.do_roth_conversion(desired_conversion_amount)
214                 money_converted += amount
215                 desired_conversion_amount -= amount
216         return money_converted
217
218     def simulate(
219             self,
220             silent: Verbosity = Verbosity.NORMAL
221     ) -> sr.SimulationResults:
222         if silent is not Verbosity.SILENT:
223             with ReportColorizer():
224                 print(self.params)
225
226         results = sr.SimulationResults.create()
227         try:
228             for year in range(2022, 2069):
229                 self.year = year
230                 self.scott_age = year - 1974
231                 self.lynn_age = year - 1964
232                 self.alex_age = year - 2005
233
234                 # Computed money needed this year based on inflated budget.
235                 money_needed = self.params.annual_expenses
236
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)
240
241                 if silent is Verbosity.VERBOSE:
242                     print(
243                         f'\nYear: {year}, estimated annual expenses {money_needed} -----------------\n'
244                     )
245                     print(
246                         f'Scott is {self.scott_age}, Lynn is {self.lynn_age} and Alex is {self.alex_age}'
247                     )
248
249                 # Print out how much money is in each account +
250                 # overall net worth.
251                 total = 0
252                 if silent is not Verbosity.VERBOSE:
253                     for x in self.accounts:
254                         total += x.get_balance()
255                 else:
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))
260
261                 # Max
262                 if total > results.max_networth:
263                     results.max_networth = total
264                     results.max_networth_year = year
265
266                 # Now, figure out how to find money to pay for this year
267                 # and how much of it is taxable.
268                 total_income = 0
269
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()
273                 if rmds > 0:
274                     logger.debug(
275                         f'Simulation: satisfied {rmds} of RMDs from age-restricted accounts.'
276                     )
277                     total_income += rmds
278                     money_needed -= rmds
279                     if rmds > results.max_rmd:
280                         results.max_rmd = rmds
281                         results.max_rmd_year = year
282
283                 # When we reach a certain age we are eligible for SS
284                 # payments.
285                 ss = self.get_social_security()
286                 results.total_social_security += ss
287                 if ss > 0:
288                     logger.debug(
289                         f'Simulation: received {ss} from social security.'
290                     )
291                     total_income += ss
292                     money_needed -= ss
293
294                 # If we still need money, try to go find it in accounts.
295                 if money_needed > 0:
296                     self.go_find_money(money_needed)
297                     total_income += money_needed
298                     money_needed = 0
299
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
310
311                     # These conversions will affect taxes due.
312                     # Recompute them now.
313                     if rollover > 0:
314                         taxes_due = self.taxes.approximate_annual_taxes()
315                         tax_rate = self.taxes.approximate_annual_tax_rate()
316                         logger.debug(
317                             f'Simulation: rolled {rollover} from pretax into Roth which increased '
318                             f'taxes by {taxes_due - old_taxes_due}'
319                         )
320
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
329                 if taxes_due > 0:
330                     logger.debug(
331                         f'Simulation: estimated {taxes_due} federal tax liability (tax rate={tax_rate})'
332                     )
333                     self.go_find_money(taxes_due)
334                 else:
335                     logger.debug('Simulation: estimating no tax burden this year')
336                 self.taxes.record_taxes_paid_and_reset_for_next_year(year)
337
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
345                 total = 0
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
352                 msg = (
353                     f'In {year}, '
354                     f'inflation={inflation.__repr__(relative=True)}, ' +
355                     f'ROI={roi.__repr__(relative=True)}, ' +
356                     f'net worth={total}'
357                 )
358                 logger.debug(msg)
359                 if silent is not Verbosity.SILENT:
360                     print(msg)
361
362         except Exception as e:
363             if silent is not Verbosity.SILENT:
364                 logger.exception(e)
365                 print(f'**** Exception, out of money in {year}? ****')
366             results.success = False
367         else:
368             results.success = True
369
370         # Final report
371         results.end_year = self.year + 1
372         results.final_networth = Money(0)
373         if results.success:
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():
380                 print(results)
381         return results