Updated.
[retire.git] / account.py
1 #!/usr/bin/env python3
2
3 from abc import abstractmethod
4 import logging
5 from typing import Optional
6
7 from type.money import Money
8
9 from person import Person
10 import data
11 from taxman import TaxCollector
12
13
14 logger = logging.getLogger(__name__)
15
16
17 class Account(object):
18     def __init__(self, name: str, owner: Person):
19         self.name = name
20         self.owner = owner
21
22     def get_name(self) -> str:
23         return self.name
24
25     def get_owner(self) -> Person:
26         return self.owner
27
28     def belongs_to_scott(self) -> bool:
29         return self.get_owner() == Person.SCOTT
30
31     def belongs_to_lynn(self) -> bool:
32         return self.get_owner() == Person.LYNN
33
34     @abstractmethod
35     def get_balance(self) -> Money:
36         """Returns the account's current balance"""
37         pass
38
39     @abstractmethod
40     def appreciate(self, rate: float) -> Money:
41         """Grow/shrink the balance using rate (<1 = shrink, 1.0=identity, >1 =
42            grow).  Return the new balance.  Raise on error.
43         """
44         pass
45
46     @abstractmethod
47     def withdraw(
48             self, amount: Money, taxes: Optional[TaxCollector] = None
49     ) -> Money:
50         """Withdraw money from the account and return the Money.  Raise
51            on error.  If the TaxCollector is passed, the money will be
52            recorded as income if applicable per the account type.
53         """
54         pass
55
56     @abstractmethod
57     def is_age_restricted(self) -> bool:
58         """Is this account age restricted.  Subclasses should implement."""
59         pass
60
61     @abstractmethod
62     def has_rmd(self) -> bool:
63         """Does this account have a required minimum distribution?  Sub-
64            classes should implement."""
65         pass
66
67     @abstractmethod
68     def do_rmd_withdrawal(self, owner_age: int, taxes: Optional[TaxCollector]) -> Money:
69         """Compute the magnitude of any RMD, withdraw it from the account and
70            return it.  If TaxCollector is provided, record the distribution as
71            income if applicable.  Raise on error."""
72         pass
73
74     @abstractmethod
75     def has_roth(self) -> bool:
76         """Does this account have a Roth balance?"""
77         pass
78
79     @abstractmethod
80     def dump_final_report(self) -> None:
81         """Produce a simulation final report."""
82         pass
83
84
85 class AgeRestrictedTaxDeferredAccount(Account):
86     """Account object used to represent an age-restricted tax deferred
87        account such as a 401(k), IRA or annuity."""
88
89     def __init__(self,
90                  name: str,
91                  owner: Person,
92                  *,
93                  total_balance: Money = Money(0),
94                  roth_subbalance: Money = Money(0)):
95         """C'tor for this class takes a roth_amount, the number of dollars
96            in the account that are in-plan Roth and can be taken out tax
97            free.  It keeps this pile separate from the pretax pile and
98            tries to estimate taxes."""
99         super().__init__(name, owner)
100         self.roth: Money = roth_subbalance
101         self.pretax: Money = total_balance - roth_subbalance
102         self.total_roth_withdrawals: Money = 0
103         self.total_pretax_withdrawals: Money = 0
104         self.total_investment_gains: Money = 0
105         self.total_roth_conversions: Money = 0
106
107     def is_age_restricted(self) -> bool:
108         return True
109
110     def withdraw(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
111         """Assume that money withdrawn from this account will be a mixture
112            of pretax funds (which count as ordinary income) and Roth funds
113            (which are available tax-free).  Assume that the ratio of pretax
114            to Roth in this overall account determines the amount from each
115            partition in this withdrawal."""
116         balance = self.get_balance()
117         if balance < amount:
118             raise Exception("Insufficient funds")
119
120         ratio = float(self.roth) / float(balance)
121         roth_part = amount * ratio
122         pretax_part = amount - roth_part
123         if roth_part > 0:
124             self.roth -= roth_part
125             self.total_roth_withdrawals += roth_part
126             logger.debug(
127                 f'Account {self.name} satisfying {amount} with {roth_part} Roth.'
128             )
129             taxes.record_roth_income(roth_part)
130
131         self.pretax -= pretax_part
132         self.total_pretax_withdrawals += pretax_part
133         logger.debug(
134             f'Account {self.name} satisfying {amount} with {pretax_part} pretax.'
135         )
136         taxes.record_ordinary_income(pretax_part)
137         return amount
138
139     def appreciate(self, rate: float) -> Money:
140         """In this class we basically ignore the balance field in favor of
141            just using pretax and roth so that we can track them separately."""
142         old_pretax = self.pretax
143         self.pretax *= rate
144         delta = self.pretax - old_pretax
145         self.total_investment_gains += delta
146         old_roth = self.roth
147         self.roth *= rate
148         delta = self.roth - old_roth
149         self.total_investment_gains += delta
150         return self.get_balance()
151
152     def get_balance(self):
153         """In this class we basically ignore the balance field in favor of
154            just using pretax and roth so that we can track them separately."""
155         return self.pretax + self.roth
156
157     def has_rmd(self):
158         return True
159
160     def do_rmd_withdrawal(self, owner_age, taxes):
161         balance = self.get_balance()
162         if balance > 0 and owner_age >= 72:
163             rmd_factor = data.get_actuary_number_years_to_live(owner_age)
164             amount = balance / rmd_factor
165             self.withdraw(amount, taxes)
166             return amount
167         return 0
168
169     def has_roth(self):
170         return True
171
172     def do_roth_conversion(self, amount: Money) -> Money:
173         if amount <= 0:
174             return Money(0)
175         if self.pretax >= amount:
176             self.roth += amount
177             self.pretax -= amount
178             self.total_roth_conversions += amount
179             logger.debug(
180                 f'Account {self.name} executed pre-tax --> Roth conversion of {amount}'
181             )
182             return amount
183         elif self.pretax > 0:
184             actual_amount = self.pretax
185             self.roth += actual_amount
186             self.pretax = 0
187             self.total_roth_conversions += actual_amount
188             logger.debug(
189                 f'Account {self.name} executed pre-tax --> Roth conversion of ' +
190                 f'{actual_amount}'
191             )
192             return actual_amount
193         return Money(0)
194
195     def dump_final_report(self):
196         print(f'Account: {self.name}:')
197         print("    %-50s: %18s" % ("Ending balance", self.get_balance()))
198         print("    %-50s: %18s" % ("Total investment gains",
199                                    self.total_investment_gains))
200         print("    %-50s: %18s" % ("Total Roth withdrawals",
201                                    self.total_roth_withdrawals))
202         print("    %-50s: %18s" % ("Total pre-tax withdrawals",
203                                    self.total_pretax_withdrawals))
204         print("    %-50s: %18s" % ("Total pre-tax converted to Roth",
205                                    self.total_roth_conversions))
206
207
208 class AgeRestrictedRothAccount(AgeRestrictedTaxDeferredAccount):
209     """This is an object to represent a Roth account like a Roth IRA.  All
210        money in here is tax free.  Most of the base account class works here
211        including the implementation of withdraw() which says that none of the
212        money withdrawn was taxable."""
213
214     def __init__(self,
215                  name: str,
216                  owner: Person,
217                  *,
218                  total_balance: Money = 0):
219         super().__init__(
220             name,
221             owner,
222             total_balance=total_balance,
223             roth_subbalance=total_balance
224         )
225
226     def has_rmd(self):
227         return False
228
229     def do_rmd_withdrawal(self, owner_age, taxes):
230         raise Exception("This account has no RMDs")
231
232     def do_roth_conversion(self, amount) -> Money:
233         return Money(0)
234
235
236 class BrokerageAccount(Account):
237     """A class to represent money in a taxable brokerage account."""
238
239     def __init__(self,
240                  name: str,
241                  owner: Person,
242                  *,
243                  total_balance = Money(0),
244                  cost_basis = Money(0)):
245         """The c'tor of this class partitions balance into three pieces:
246            the cost_basis (i.e. how much money was invested in the account),
247            the short_term_gain (i.e. appreciation that has been around for
248            less than a year) and long_term_gain (i.e. appreciation that has
249            been around for more than a year).  We separate these because
250            taxes on long_term_gain (and qualified dividends, which are not
251            modeled) are usually lower than short_term_gain.  Today those
252            are taxed at 15% and as ordinary income, respectively."""
253         super().__init__(name, owner)
254         self.cost_basis = cost_basis
255         self.short_term_gain = Money(0)
256         self.long_term_gain = total_balance - cost_basis
257         self.total_cost_basis_withdrawals = Money(0)
258         self.total_long_term_gain_withdrawals = Money(0)
259         self.total_short_term_gain_withdrawals = Money(0)
260         self.total_investment_gains = Money(0)
261
262     def withdraw(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
263         """Override the base class' withdraw implementation since we're
264            dealing with three piles of money instead of one.  When you sell
265            securities to get money out of this account the gains are taxed
266            (and the cost_basis part isn't).  Assume that the ratio of
267            cost_basis to overall balance can be used to determine how much
268            of the withdrawal will be taxed (and how)."""
269         balance = self.get_balance()
270         if balance < amount:
271             raise Exception("Insufficient funds")
272
273         if self.cost_basis > 0 and (self.short_term_gain + self.long_term_gain) > 0:
274             return self._withdraw_with_ratio(amount, taxes)
275         else:
276             return self._withdraw_waterfall(amount, taxes)
277
278     def _withdraw_short_term_gain(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
279         if self.short_term_gain >= amount:
280             self.short_term_gain -= amount
281             self.total_short_term_gain_withdrawals += amount
282             if taxes is not None:
283                 taxes.record_short_term_gain(amount)
284             return amount
285         raise Exception('Insufficient funds')
286
287     def _withdraw_long_term_gain(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
288         if self.long_term_gain >= amount:
289             self.long_term_gain -= amount
290             self.total_long_term_gain_withdrawals += amount
291             if taxes is not None:
292                 taxes.record_dividend_or_long_term_gain(amount)
293             return amount
294         raise Exception('Insufficient funds')
295
296     def _withdraw_cost_basis(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
297         if self.cost_basis >= amount:
298             self.cost_basis -= amount
299             self.total_cost_basis_withdrawals += amount
300             return amount
301         raise Exception('Insufficient funds')
302
303     def _withdraw_with_ratio(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
304         ratio = float(self.cost_basis) / float(self.get_balance())
305         invested_capital_part = amount * ratio
306         invested_capital_part.truncate_fractional_cents()
307         gains_part = amount - invested_capital_part
308         gains_part -= 0.01
309         if self.cost_basis >= invested_capital_part:
310             self._withdraw_cost_basis(invested_capital_part, taxes)
311             logger.debug(
312                 f'Account {self.name}: satisfying {invested_capital_part} from cost basis funds.'
313             )
314             logger.debug(
315                 f'Account {self.name}: satisfying {gains_part} from investment gains...'
316             )
317             self._withdraw_from_gains(gains_part, taxes)
318         else:
319             logger.debug(
320                 f'Account {self.name}: satisfying {gains_part} from investment gains...'
321             )
322             self._withdraw_from_gains(amount, taxes)
323         return amount
324
325     def _withdraw_waterfall(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
326         to_find = amount
327         if self.short_term_gain > 0:
328             if to_find < self.short_term_gain:
329                 self.short_term_gain -= to_find
330                 self.total_short_term_gain_withdrawals += to_find
331                 to_find = Money(0)
332             else:
333                 to_find -= self.short_term_gain
334                 self.total_short_term_gain_withdrawals += self.short_term_gain
335                 self.short_term_gain = Money(0)
336         if self.long_term_gain > 0:
337             if to_find < self.long_term_gain:
338                 self.long_term_gain -= to_find
339                 self.total_long_term_gain_withdrawals += to_find
340                 to_find = Money(0)
341             else:
342                 to_find -= self.long_term_gain
343                 self.total_long_term_gain_withdrawals += self.long_term_gain
344                 self.long_term_gain = Money(0)
345         if self.cost_basis > 0:
346             if to_find < self.cost_basis:
347                 self.cost_basis -= to_find
348                 to_find = Money(0)
349             else:
350                 to_find -= self.cost_basis
351                 self.cost_basis = Money(0)
352         assert(to_find == Money(0))
353
354     def _withdraw_from_gains(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
355         """Withdraw some money from gains.  Prefer the long term ones if
356            possible."""
357         to_find = amount
358         if to_find > (self.long_term_gain + self.short_term_gain):
359             raise Exception("Insufficient funds")
360
361         if self.long_term_gain >= to_find:
362             self._withdraw_long_term_gain(to_find, taxes)
363             logger.debug(
364                 f'Account {self.name}: satisfying {to_find} from long term gains.'
365             )
366             return to_find
367
368         logger.debug(
369             f'Account {self.name}: satisfying {self.long_term_gain} from long term gains ' +
370             '(exhausting long term gains)'
371         )
372         self._withdraw_long_term_gain(self.long_term_gain, taxes)
373         to_find -= self.long_term_gain
374         self._withdraw_short_term_gain(to_find, taxes)
375         logger.debug(
376             f'Account {self.name}: satisfying {to_find} from short term gains'
377         )
378         return amount
379
380     def get_balance(self) -> Money:
381         """We're ignoring the base class' balance field in favor of tracking
382            it as three separate partitions of money."""
383         return self.cost_basis + self.long_term_gain + self.short_term_gain
384
385     def appreciate(self, rate: float) -> Money:
386         """Appreciate... another year has passed so short_term_gains turn into
387            long_term_gains and the appreciation is our new short_term_gains."""
388         balance = self.get_balance()
389         gain = balance * (rate - 1.0)  # Note: rate is something like 1.04
390         self.total_investment_gains += gain
391         self.long_term_gain += self.short_term_gain
392         self.short_term_gain = gain
393         return self.get_balance()
394
395     def is_age_restricted(self) -> bool:
396         return False
397
398     def has_rmd(self) -> bool:
399         return False
400
401     def has_roth(self) -> bool:
402         return False
403
404     def do_roth_conversion(self, amount):
405         return Money(0)
406
407     def dump_final_report(self):
408         print(f'Account {self.name}:')
409         print("    %-50s: %18s" % ("Ending balance", self.get_balance()))
410         print("    %-50s: %18s" % ("Total investment gains",
411                                    self.total_investment_gains))
412         print("    %-50s: %18s" % ("Total cost basis withdrawals",
413                                    self.total_cost_basis_withdrawals))
414         print("    %-50s: %18s" % ("Total long term gain withdrawals",
415                                    self.total_long_term_gain_withdrawals))
416         print("    %-50s: %18s" % ("Total short term gain withdrawals",
417                                    self.total_short_term_gain_withdrawals))