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