3 from abc import abstractmethod
5 from typing import Optional
7 from type.money import Money
9 from person import Person
11 from taxman import TaxCollector
14 logger = logging.getLogger(__name__)
17 class Account(object):
18 def __init__(self, name: str, owner: Person):
22 def get_name(self) -> str:
25 def get_owner(self) -> Person:
28 def belongs_to_scott(self) -> bool:
29 return self.get_owner() == Person.SCOTT
31 def belongs_to_lynn(self) -> bool:
32 return self.get_owner() == Person.LYNN
35 def get_balance(self) -> Money:
36 """Returns the account's current balance"""
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.
48 self, amount: Money, taxes: Optional[TaxCollector] = None
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.
57 def is_age_restricted(self) -> bool:
58 """Is this account age restricted. Subclasses should implement."""
62 def has_rmd(self) -> bool:
63 """Does this account have a required minimum distribution? Sub-
64 classes should implement."""
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."""
75 def has_roth(self) -> bool:
76 """Does this account have a Roth balance?"""
80 def dump_final_report(self) -> None:
81 """Produce a simulation final report."""
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."""
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
107 def is_age_restricted(self) -> bool:
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()
118 raise Exception("Insufficient funds")
120 ratio = float(self.roth) / float(balance)
121 roth_part = amount * ratio
122 pretax_part = amount - roth_part
124 self.roth -= roth_part
125 self.total_roth_withdrawals += roth_part
127 f'Account {self.name} satisfying {amount} with {roth_part} Roth.'
129 taxes.record_roth_income(roth_part)
131 self.pretax -= pretax_part
132 self.total_pretax_withdrawals += pretax_part
134 f'Account {self.name} satisfying {amount} with {pretax_part} pretax.'
136 taxes.record_ordinary_income(pretax_part)
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
144 delta = self.pretax - old_pretax
145 self.total_investment_gains += delta
148 delta = self.roth - old_roth
149 self.total_investment_gains += delta
150 return self.get_balance()
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
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)
172 def do_roth_conversion(self, amount: Money) -> Money:
175 if self.pretax >= amount:
177 self.pretax -= amount
178 self.total_roth_conversions += amount
180 f'Account {self.name} executed pre-tax --> Roth conversion of {amount}'
183 elif self.pretax > 0:
184 actual_amount = self.pretax
185 self.roth += actual_amount
187 self.total_roth_conversions += actual_amount
189 f'Account {self.name} executed pre-tax --> Roth conversion of ' +
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))
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."""
218 total_balance: Money = 0):
222 total_balance=total_balance,
223 roth_subbalance=total_balance
229 def do_rmd_withdrawal(self, owner_age, taxes):
230 raise Exception("This account has no RMDs")
232 def do_roth_conversion(self, amount) -> Money:
236 class BrokerageAccount(Account):
237 """A class to represent money in a taxable brokerage account."""
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)
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()
271 raise Exception("Insufficient funds")
273 if self.cost_basis > 0 and (self.short_term_gain + self.long_term_gain) > 0:
274 return self._withdraw_with_ratio(amount, taxes)
276 return self._withdraw_waterfall(amount, taxes)
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)
285 raise Exception('Insufficient funds')
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)
294 raise Exception('Insufficient funds')
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
301 raise Exception('Insufficient funds')
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
309 if self.cost_basis >= invested_capital_part:
310 self._withdraw_cost_basis(invested_capital_part, taxes)
312 f'Account {self.name}: satisfying {invested_capital_part} from cost basis funds.'
315 f'Account {self.name}: satisfying {gains_part} from investment gains...'
317 self._withdraw_from_gains(gains_part, taxes)
320 f'Account {self.name}: satisfying {gains_part} from investment gains...'
322 self._withdraw_from_gains(amount, taxes)
325 def _withdraw_waterfall(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
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
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
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
350 to_find -= self.cost_basis
351 self.cost_basis = Money(0)
352 assert(to_find == Money(0))
354 def _withdraw_from_gains(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
355 """Withdraw some money from gains. Prefer the long term ones if
358 if to_find > (self.long_term_gain + self.short_term_gain):
359 raise Exception("Insufficient funds")
361 if self.long_term_gain >= to_find:
362 self._withdraw_long_term_gain(to_find, taxes)
364 f'Account {self.name}: satisfying {to_find} from long term gains.'
369 f'Account {self.name}: satisfying {self.long_term_gain} from long term gains ' +
370 '(exhausting long term gains)'
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)
376 f'Account {self.name}: satisfying {to_find} from short term gains'
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
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()
395 def is_age_restricted(self) -> bool:
398 def has_rmd(self) -> bool:
401 def has_roth(self) -> bool:
404 def do_roth_conversion(self, amount):
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))