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