7867d21b4b18836a502b4fac1c3c0df36b60958a
[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
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):
241         if amount <= 0:
242             return 0
243         if self.pretax >= amount:
244             self.roth += amount
245             self.pretax -= amount
246             self.total_roth_conversions += 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         elif self.pretax > 0:
252             actual_amount = self.pretax
253             self.roth += actual_amount
254             self.pretax = 0
255             self.total_roth_conversions += actual_amount
256             print "## Executed pre-tax --> Roth conversion of %s in %s" % (
257                 utils.format_money(actual_amount),
258                 self.get_name())
259             return actual_amount
260         return 0
261
262     def dump_final_report(self):
263         super(age_restricted_tax_deferred_account, self).dump_final_report()
264         print "    {:<50}: {:>14}".format("Initial balance",
265                                           utils.format_money(self.initial_balance))
266         print "    {:<50}: {:>14}".format("Ending balance",
267                                         utils.format_money(self.get_balance()))
268         print "    {:<50}: {:>14}".format("Total investment gains",
269                                         utils.format_money(self.total_investment_gains))
270         print "    {:<50}: {:>14}".format("Total Roth withdrawals",
271                                         utils.format_money(self.total_roth_withdrawals))
272         print "    {:<50}: {:>14}".format("Total pre-tax withdrawals",
273                                         utils.format_money(self.total_pretax_withdrawals))
274         print "    {:<50}: {:>14}".format("Total pre-tax converted to Roth",
275                                         utils.format_money(self.total_roth_conversions))
276
277 class age_restricted_roth_account(age_restricted_tax_deferred_account):
278     """This is an object to represent a Roth account like a Roth IRA.  All
279        money in here is tax free.  Most of the base account class works here
280        including the implementation of withdraw() which says that none of the
281        money withdrawn was taxable."""
282
283     def __init__(self, total_balance, name, owner):
284         """C'tor for this class takes a roth_amount, the number of dollars
285            in the account that are in-plan Roth and can be taken out tax
286            free.  It keeps this pile separate from the pretax pile and
287            tries to estimate taxes."""
288         age_restricted_tax_deferred_account.__init__(
289             self, total_balance, 0, name, owner)
290
291     # Override
292     def has_rmd(self):
293         return False
294
295     # Override
296     def do_rmd_withdrawal(self, owner_age, taxes):
297         raise Exception("This account has no RMDs")
298
299     # Override
300     def do_roth_conversion(self, amount):
301         return 0
302
303 class brokerage_account(account):
304     """A class to represent money in a taxable brokerage account."""
305
306     def __init__(self, total_balance, cost_basis, name, owner):
307         """The c'tor of this class partitions balance into three pieces:
308            the cost_basis (i.e. how much money was invested in the account),
309            the short_term_gain (i.e. appreciation that has been around for
310            less than a year) and long_term_gain (i.e. appreciation that has
311            been around for more than a year).  We separate these because
312            taxes on long_term_gain (and qualified dividends, which are not
313            modeled) are usually lower than short_term_gain.  Today those
314            are taxed at 15% and as ordinary income, respectively."""
315         self.cost_basis = float(cost_basis)
316         self.short_term_gain = 0.0
317         self.long_term_gain = float(total_balance - cost_basis)
318         self.total_cost_basis_withdrawals = 0
319         self.total_long_term_gain_withdrawals = 0
320         self.total_short_term_gain_withdrawals = 0
321         self.total_investment_gains = 0
322         self.initial_balance = self.get_balance()
323
324         # Call the base class' c'tor as well to set up the name.
325         account.__init__(self, name, owner)
326
327     def withdraw(self, amount, taxes):
328         """Override the base class' withdraw implementation since we're
329            dealing with three piles of money instead of one.  When you sell
330            securities to get money out of this account the gains are taxed
331            (and the cost_basis part isn't).  Assume that the ratio of
332            cost_basis to overall balance can be used to determine how much
333            of the withdrawal will be taxed (and how)."""
334         balance = self.get_balance()
335         if balance < amount:
336             raise Exception("Insufficient funds")
337
338         ratio = self.cost_basis / balance
339         invested_capital_part = amount * ratio
340         gains_part = amount - invested_capital_part
341
342         if self.cost_basis >= invested_capital_part:
343             self.cost_basis -= invested_capital_part
344             self.total_cost_basis_withdrawals += invested_capital_part
345             print "## Satisfying %s from %s as cost basis." % (utils.format_money(invested_capital_part), self.name)
346             self.withdraw_from_gains(gains_part, taxes)
347         else:
348             self.withdraw_from_gains(amount, taxes)
349
350     def withdraw_from_gains(self, amount, taxes):
351         """Withdraw some money from gains.  Prefer the long term ones if
352            possible."""
353         if amount > (self.long_term_gain + self.short_term_gain):
354             raise Exception("Insufficient funds")
355
356         if self.long_term_gain >= amount:
357             self.long_term_gain -= amount
358             self.total_long_term_gain_withdrawals += amount
359             print "## Satisfying %s from %s as long term gains." % (
360                 utils.format_money(amount),
361                 self.name)
362             taxes.record_dividend_or_long_term_gain(amount)
363             return
364
365         else:
366             print "## Satisfying %s from %s as long term gains (exhausting all current long term gains in account)." % (
367                 utils.format_money(self.long_term_gain),
368                 self.name)
369             amount -= self.long_term_gain
370             self.total_long_term_gain_withdrawals += self.long_term_gain
371             taxes.record_dividend_or_long_term_gain(self.long_term_gain)
372             self.long_term_gain = 0
373             # Get the rest of amount from short term gains...
374
375         self.short_term_gain -= amount
376         self.total_short_term_gain_withdrawals += amount
377         print "## Satisfying %s from %s as short term gains." % (
378             utils.format_money(amount),
379             self.name)
380         taxes.record_short_term_gain(amount)
381
382     def deposit(self, amount):
383         self.cost_basis += amount
384
385     def get_balance(self):
386         """We're ignoring the base class' balance field in favor of tracking
387            it as three separate partitions of money."""
388         return self.cost_basis + self.long_term_gain + self.short_term_gain
389
390     def appreciate(self, multiplier):
391         """Appreciate... another year has passed so short_term_gains turn into
392            long_term_gains and the appreciation is our new short_term_gains."""
393         balance = self.get_balance()
394         balance *= multiplier
395         gain_or_loss = balance - self.get_balance()
396         self.total_investment_gains += gain_or_loss
397         self.long_term_gain += self.short_term_gain
398         self.short_term_gain = gain_or_loss
399
400     def is_age_restricted(self):
401         return False
402
403     def has_rmd(self):
404         return False
405
406     def has_roth(self):
407         return False
408
409     def do_roth_conversion(self, amount):
410         return 0
411
412     def dump_final_report(self):
413         super(brokerage_account, self).dump_final_report()
414         print "    {:<50}: {:>14}".format("Initial balance",
415                                           utils.format_money(self.initial_balance))
416         print "    {:<50}: {:>14}".format("Ending balance",
417                                         utils.format_money(self.get_balance()))
418         print "    {:<50}: {:>14}".format("Total investment gains",
419                                         utils.format_money(self.total_investment_gains))
420         print "    {:<50}: {:>14}".format("Total cost basis withdrawals",
421                                         utils.format_money(self.total_cost_basis_withdrawals))
422         print "    {:<50}: {:>14}".format("Total long term gain withdrawals",
423                                         utils.format_money(self.total_long_term_gain_withdrawals))
424         print "    {:<50}: {:>14}".format("Total short term gain withdrawals",
425                                         utils.format_money(self.total_short_term_gain_withdrawals))