Modify the roth conversion logic and simplify the code around that.
[retire.git] / accounts.py
1 import constants
2 import utils
3
4 class account(object):
5     """This defines an account base class which inherits from object, the
6        default base class in python.  This is not a 100% abstract class, it
7        includes some default fields (like balance and name) and methods that
8        define behaviors (withdraw, appreciate, etc...).  Several subclasses
9        are defined below."""
10
11     def __init__(self, name, owner):
12         """Constructor for account object takes a name and owner"""
13         self.name = name
14         self.owner = owner
15
16     def get_name(self):
17         return self.name
18
19     def get_owner(self):
20         return self.owner
21
22     def belongs_to_scott(self):
23         return self.get_owner() == constants.SCOTT
24
25     def belongs_to_lynn(self):
26         return self.get_owner() == constants.LYNN
27
28     def get_balance(self):
29         pass
30
31     def appreciate(self, multiplier):
32         """Grow the balance using rate."""
33         pass
34
35     def withdraw(self, amount, taxes):
36         """Withdraw money from the account if possible.  Throws otherwise.
37            Money should be registered with taxes so that we can approximate
38            taxes due, too."""
39         pass
40
41     def deposit(self, amount):
42         pass
43
44     def is_age_restricted(self):
45         """Is this account age restricted.  Subclasses should implement."""
46         pass
47
48     def has_rmd(self):
49         """Does this account have a required minimum distribution?  Sub-
50            classes should implement."""
51         pass
52
53     def do_rmd_withdrawal(self, owner_age, taxes):
54         pass
55
56     def has_roth(self):
57         pass
58
59     def dump_final_report(self):
60         print "  " + self.name + ":"
61
62 class age_restricted_tax_deferred_account(account):
63     """Account object used to represent an age-restricted tax deferred
64        account such as a 401(k), IRA or annuity."""
65
66     def __init__(self, total_balance, roth_amount_subset, name, owner):
67         """C'tor for this class takes a roth_amount, the number of dollars
68            in the account that are in-plan Roth and can be taken out tax
69            free.  It keeps this pile separate from the pretax pile and
70            tries to estimate taxes."""
71         self.roth = roth_amount_subset
72         self.pretax = total_balance - roth_amount_subset
73         self.total_roth_withdrawals = 0
74         self.total_pretax_withdrawals = 0
75         self.total_investment_gains = 0
76         self.total_roth_conversions = 0
77         self.initial_balance = self.get_balance()
78
79         # This calls the super class' c'tor.
80         account.__init__(self, name, owner)
81
82     # These accounts are all age-restricted
83     def is_age_restricted(self):
84         return True
85
86     def withdraw(self, amount, taxes):
87         """Assume that money withdrawn from this account will be a mixture
88            of pretax funds (which count as ordinary income) and Roth funds
89            (which are available tax-free).  Assume that the ratio of pretax
90            to Roth in this overall account determines the amount from each
91            partition in this withdrawal."""
92         balance = self.get_balance()
93         if balance < amount:
94             raise Exception("Insufficient funds")
95
96         ratio = float(self.roth) / float(balance)
97         roth_part = amount * ratio
98         pretax_part = amount - roth_part
99         if roth_part > 0:
100             self.roth -= roth_part
101             self.total_roth_withdrawals += roth_part
102             print "## Satisfying %s from %s with Roth money." % (utils.format_money(roth_part), self.name)
103             taxes.record_roth_income(roth_part)
104         if pretax_part > 0:
105             self.pretax -= pretax_part
106             self.total_pretax_withdrawals += pretax_part
107             print "## Satisfying %s from %s with pre-tax money." % (utils.format_money(pretax_part), self.name)
108             taxes.record_ordinary_income(pretax_part)
109
110     def deposit(self, amount):
111         self.pretax_part += amount
112
113     def appreciate(self, multiplier):
114         """In this class we basically ignore the balance field in favor of
115            just using pretax and roth so that we can track them separately."""
116         old_pretax = self.pretax
117         self.pretax *= multiplier
118         self.total_investment_gains += self.pretax - old_pretax
119         old_roth = self.roth
120         self.roth *= multiplier
121         self.total_investment_gains += self.roth - old_roth
122
123     def get_balance(self):
124         """In this class we keep the balance in two parts."""
125         return self.pretax + self.roth
126
127     def has_rmd(self):
128         return True
129
130     # Used to compute RMDs.  Source:
131     # https://www.irs.gov/publications/p590b#en_US_2018_publink1000231258
132     def get_actuary_number_years_to_live(self, age):
133         if age < 70:
134             return 27.4 + age
135         elif age == 70:
136             return 27.4
137         elif age == 71:
138             return 26.5
139         elif age == 72:
140             return 25.6
141         elif age == 73:
142             return 24.7
143         elif age == 74:
144             return 23.8
145         elif age == 75:
146             return 22.9
147         elif age == 76:
148             return 22.0
149         elif age == 77:
150             return 21.2
151         elif age == 78:
152             return 20.3
153         elif age == 79:
154             return 19.5
155         elif age == 80:
156             return 18.7
157         elif age == 81:
158             return 17.9
159         elif age == 82:
160             return 17.1
161         elif age == 83:
162             return 16.3
163         elif age == 84:
164             return 15.5
165         elif age == 85:
166             return 14.8
167         elif age == 86:
168             return 14.1
169         elif age == 87:
170             return 13.4
171         elif age == 88:
172             return 12.7
173         elif age == 89:
174             return 12.0
175         elif age == 90:
176             return 11.4
177         elif age == 91:
178             return 10.8
179         elif age == 92:
180             return 10.2
181         elif age == 93:
182             return 9.6
183         elif age == 94:
184             return 9.1
185         elif age == 95:
186             return 8.6
187         elif age == 96:
188             return 8.1
189         elif age == 97:
190             return 7.6
191         elif age == 98:
192             return 7.1
193         elif age == 99:
194             return 6.7
195         elif age == 100:
196             return 6.3
197         elif age == 101:
198             return 5.9
199         elif age == 102:
200             return 5.5
201         elif age == 103:
202             return 5.2
203         elif age == 104:
204             return 4.9
205         elif age == 105:
206             return 4.5
207         elif age == 106:
208             return 4.2
209         elif age == 107:
210             return 3.9
211         elif age == 108:
212             return 3.7
213         elif age == 109:
214             return 3.4
215         elif age == 110:
216             return 3.1
217         elif age == 111:
218             return 2.9
219         elif age == 112:
220             return 2.6
221         elif age == 113:
222             return 2.4
223         elif age == 114:
224             return 2.1
225         else:
226             return 1.9
227
228     def do_rmd_withdrawal(self, owner_age, taxes):
229         balance = self.get_balance()
230         if balance > 0 and owner_age >= 72:
231             rmd_factor = self.get_actuary_number_years_to_live(owner_age)
232             amount = balance / rmd_factor
233             self.withdraw(amount, taxes)
234             return amount
235         return 0
236
237     def has_roth(self):
238         return True
239
240     def do_roth_conversion(self, amount, taxes):
241         if amount <= 0: return 0
242         amount = min(amount, self.pretax)
243         self.roth += amount
244         self.pretax -= amount
245         self.total_roth_conversions += amount
246         taxes.record_ordinary_income(amount)
247         print "## Executed pre-tax --> Roth conversion of %s in %s" % (
248             utils.format_money(amount),
249             self.get_name())
250         return amount
251
252     def dump_final_report(self):
253         super(age_restricted_tax_deferred_account, self).dump_final_report()
254         print "    {:<50}: {:>14}".format("Initial balance",
255                                           utils.format_money(self.initial_balance))
256         print "    {:<50}: {:>14}".format("Ending balance",
257                                         utils.format_money(self.get_balance()))
258         print "    {:<50}: {:>14}".format("Total investment gains",
259                                         utils.format_money(self.total_investment_gains))
260         print "    {:<50}: {:>14}".format("Total Roth withdrawals",
261                                         utils.format_money(self.total_roth_withdrawals))
262         print "    {:<50}: {:>14}".format("Total pre-tax withdrawals",
263                                         utils.format_money(self.total_pretax_withdrawals))
264         print "    {:<50}: {:>14}".format("Total pre-tax converted to Roth",
265                                         utils.format_money(self.total_roth_conversions))
266
267 class age_restricted_roth_account(age_restricted_tax_deferred_account):
268     """This is an object to represent a Roth account like a Roth IRA.  All
269        money in here is tax free.  Most of the base account class works here
270        including the implementation of withdraw() which says that none of the
271        money withdrawn was taxable."""
272
273     def __init__(self, total_balance, name, owner):
274         """C'tor for this class takes a roth_amount, the number of dollars
275            in the account that are in-plan Roth and can be taken out tax
276            free.  It keeps this pile separate from the pretax pile and
277            tries to estimate taxes."""
278         age_restricted_tax_deferred_account.__init__(
279             self, total_balance, total_balance, name, owner)
280
281     # Override
282     def has_rmd(self):
283         return False
284
285     # Override
286     def do_rmd_withdrawal(self, owner_age, taxes):
287         raise Exception("This account has no RMDs")
288
289     # Override
290     def do_roth_conversion(self, amount, taxes):
291         return 0
292
293 class brokerage_account(account):
294     """A class to represent money in a taxable brokerage account."""
295
296     def __init__(self, total_balance, cost_basis, name, owner):
297         """The c'tor of this class partitions balance into three pieces:
298            the cost_basis (i.e. how much money was invested in the account),
299            the short_term_gain (i.e. appreciation that has been around for
300            less than a year) and long_term_gain (i.e. appreciation that has
301            been around for more than a year).  We separate these because
302            taxes on long_term_gain (and qualified dividends, which are not
303            modeled) are usually lower than short_term_gain.  Today those
304            are taxed at 15% and as ordinary income, respectively."""
305         self.cost_basis = float(cost_basis)
306         self.short_term_gain = 0.0
307         self.long_term_gain = float(total_balance - cost_basis)
308         self.total_cost_basis_withdrawals = 0
309         self.total_long_term_gain_withdrawals = 0
310         self.total_short_term_gain_withdrawals = 0
311         self.total_investment_gains = 0
312         self.initial_balance = self.get_balance()
313
314         # Call the base class' c'tor as well to set up the name.
315         account.__init__(self, name, owner)
316
317     def withdraw(self, amount, taxes):
318         """Override the base class' withdraw implementation since we're
319            dealing with three piles of money instead of one.  When you sell
320            securities to get money out of this account the gains are taxed
321            (and the cost_basis part isn't).  Assume that the ratio of
322            cost_basis to overall balance can be used to determine how much
323            of the withdrawal will be taxed (and how)."""
324         balance = self.get_balance()
325         if balance < amount:
326             raise Exception("Insufficient funds")
327
328         ratio = self.cost_basis / balance
329         invested_capital_part = amount * ratio
330         gains_part = amount - invested_capital_part
331
332         if self.cost_basis >= invested_capital_part:
333             self.cost_basis -= invested_capital_part
334             self.total_cost_basis_withdrawals += invested_capital_part
335             print "## Satisfying %s from %s as cost basis." % (utils.format_money(invested_capital_part), self.name)
336             self.withdraw_from_gains(gains_part, taxes)
337         else:
338             self.withdraw_from_gains(amount, taxes)
339
340     def withdraw_from_gains(self, amount, taxes):
341         """Withdraw some money from gains.  Prefer the long term ones if
342            possible."""
343         if amount > (self.long_term_gain + self.short_term_gain):
344             raise Exception("Insufficient funds")
345
346         if self.long_term_gain >= amount:
347             self.long_term_gain -= amount
348             self.total_long_term_gain_withdrawals += amount
349             print "## Satisfying %s from %s as long term gains." % (
350                 utils.format_money(amount),
351                 self.name)
352             taxes.record_dividend_or_long_term_gain(amount)
353             return
354
355         else:
356             print "## Satisfying %s from %s as long term gains (exhausting all current long term gains in account)." % (
357                 utils.format_money(self.long_term_gain),
358                 self.name)
359             amount -= self.long_term_gain
360             self.total_long_term_gain_withdrawals += self.long_term_gain
361             taxes.record_dividend_or_long_term_gain(self.long_term_gain)
362             self.long_term_gain = 0
363             # Get the rest of amount from short term gains...
364
365         self.short_term_gain -= amount
366         self.total_short_term_gain_withdrawals += amount
367         print "## Satisfying %s from %s as short term gains." % (
368             utils.format_money(amount),
369             self.name)
370         taxes.record_short_term_gain(amount)
371
372     def deposit(self, amount):
373         self.cost_basis += amount
374
375     def get_balance(self):
376         """We're ignoring the base class' balance field in favor of tracking
377            it as three separate partitions of money."""
378         return self.cost_basis + self.long_term_gain + self.short_term_gain
379
380     def appreciate(self, multiplier):
381         """Appreciate... another year has passed so short_term_gains turn into
382            long_term_gains and the appreciation is our new short_term_gains."""
383         balance = self.get_balance()
384         balance *= multiplier
385         gain_or_loss = balance - self.get_balance()
386         self.total_investment_gains += gain_or_loss
387         self.long_term_gain += self.short_term_gain
388         self.short_term_gain = gain_or_loss
389
390     def is_age_restricted(self):
391         return False
392
393     def has_rmd(self):
394         return False
395
396     def has_roth(self):
397         return False
398
399     def do_roth_conversion(self, amount, taxes):
400         return 0
401
402     def dump_final_report(self):
403         super(brokerage_account, self).dump_final_report()
404         print "    {:<50}: {:>14}".format("Initial balance",
405                                           utils.format_money(self.initial_balance))
406         print "    {:<50}: {:>14}".format("Ending balance",
407                                         utils.format_money(self.get_balance()))
408         print "    {:<50}: {:>14}".format("Total investment gains",
409                                         utils.format_money(self.total_investment_gains))
410         print "    {:<50}: {:>14}".format("Total cost basis withdrawals",
411                                         utils.format_money(self.total_cost_basis_withdrawals))
412         print "    {:<50}: {:>14}".format("Total long term gain withdrawals",
413                                         utils.format_money(self.total_long_term_gain_withdrawals))
414         print "    {:<50}: {:>14}".format("Total short term gain withdrawals",
415                                         utils.format_money(self.total_short_term_gain_withdrawals))