+++ /dev/null
-*.pyc
-*secret*
--- /dev/null
+#!/usr/bin/env python3
+
+from abc import abstractmethod
+import logging
+from typing import Optional
+
+from type.money import Money
+
+from person import Person
+import data
+from taxman import TaxCollector
+
+
+logger = logging.getLogger(__name__)
+
+
+class Account(object):
+ def __init__(self, name: str, owner: Person):
+ self.name = name
+ self.owner = owner
+
+ def get_name(self) -> str:
+ return self.name
+
+ def get_owner(self) -> Person:
+ return self.owner
+
+ def belongs_to_scott(self) -> bool:
+ return self.get_owner() == Person.SCOTT
+
+ def belongs_to_lynn(self) -> bool:
+ return self.get_owner() == Person.LYNN
+
+ @abstractmethod
+ def get_balance(self) -> Money:
+ """Returns the account's current balance"""
+ pass
+
+ @abstractmethod
+ def appreciate(self, rate: float) -> Money:
+ """Grow/shrink the balance using rate (<1 = shrink, 1.0=identity, >1 =
+ grow). Return the new balance. Raise on error.
+ """
+ pass
+
+ @abstractmethod
+ def withdraw(
+ self, amount: Money, taxes: Optional[TaxCollector] = None
+ ) -> Money:
+ """Withdraw money from the account and return the Money. Raise
+ on error. If the TaxCollector is passed, the money will be
+ recorded as income if applicable per the account type.
+ """
+ pass
+
+ @abstractmethod
+ def is_age_restricted(self) -> bool:
+ """Is this account age restricted. Subclasses should implement."""
+ pass
+
+ @abstractmethod
+ def has_rmd(self) -> bool:
+ """Does this account have a required minimum distribution? Sub-
+ classes should implement."""
+ pass
+
+ @abstractmethod
+ def do_rmd_withdrawal(self, owner_age: int, taxes: Optional[TaxCollector]) -> Money:
+ """Compute the magnitude of any RMD, withdraw it from the account and
+ return it. If TaxCollector is provided, record the distribution as
+ income if applicable. Raise on error."""
+ pass
+
+ @abstractmethod
+ def has_roth(self) -> bool:
+ """Does this account have a Roth balance?"""
+ pass
+
+ @abstractmethod
+ def dump_final_report(self) -> None:
+ """Produce a simulation final report."""
+ pass
+
+
+class AgeRestrictedTaxDeferredAccount(Account):
+ """Account object used to represent an age-restricted tax deferred
+ account such as a 401(k), IRA or annuity."""
+
+ def __init__(self,
+ name: str,
+ owner: Person,
+ *,
+ total_balance: Money = Money(0),
+ roth_subbalance: Money = Money(0)):
+ """C'tor for this class takes a roth_amount, the number of dollars
+ in the account that are in-plan Roth and can be taken out tax
+ free. It keeps this pile separate from the pretax pile and
+ tries to estimate taxes."""
+ super().__init__(name, owner)
+ self.roth: Money = roth_subbalance
+ self.pretax: Money = total_balance - roth_subbalance
+ self.total_roth_withdrawals: Money = 0
+ self.total_pretax_withdrawals: Money = 0
+ self.total_investment_gains: Money = 0
+ self.total_roth_conversions: Money = 0
+
+ def is_age_restricted(self) -> bool:
+ return True
+
+ def withdraw(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
+ """Assume that money withdrawn from this account will be a mixture
+ of pretax funds (which count as ordinary income) and Roth funds
+ (which are available tax-free). Assume that the ratio of pretax
+ to Roth in this overall account determines the amount from each
+ partition in this withdrawal."""
+ balance = self.get_balance()
+ if balance < amount:
+ raise Exception("Insufficient funds")
+
+ ratio = float(self.roth) / float(balance)
+ roth_part = amount * ratio
+ pretax_part = amount - roth_part
+ if roth_part > 0:
+ self.roth -= roth_part
+ self.total_roth_withdrawals += roth_part
+ logger.debug(
+ f'Account {self.name} satisfying {amount} with {roth_part} Roth.'
+ )
+ taxes.record_roth_income(roth_part)
+
+ self.pretax -= pretax_part
+ self.total_pretax_withdrawals += pretax_part
+ logger.debug(
+ f'Account {self.name} satisfying {amount} with {pretax_part} pretax.'
+ )
+ taxes.record_ordinary_income(pretax_part)
+ return amount
+
+ def appreciate(self, rate: float) -> Money:
+ """In this class we basically ignore the balance field in favor of
+ just using pretax and roth so that we can track them separately."""
+ old_pretax = self.pretax
+ self.pretax *= rate
+ delta = self.pretax - old_pretax
+ self.total_investment_gains += delta
+ old_roth = self.roth
+ self.roth *= rate
+ delta = self.roth - old_roth
+ self.total_investment_gains += delta
+ return self.get_balance()
+
+ def get_balance(self):
+ """In this class we basically ignore the balance field in favor of
+ just using pretax and roth so that we can track them separately."""
+ return self.pretax + self.roth
+
+ def has_rmd(self):
+ return True
+
+ def do_rmd_withdrawal(self, owner_age, taxes):
+ balance = self.get_balance()
+ if balance > 0 and owner_age >= 72:
+ rmd_factor = data.get_actuary_number_years_to_live(owner_age)
+ amount = balance / rmd_factor
+ self.withdraw(amount, taxes)
+ return amount
+ return 0
+
+ def has_roth(self):
+ return True
+
+ def do_roth_conversion(self, amount: Money) -> Money:
+ if amount <= 0:
+ return Money(0)
+ if self.pretax >= amount:
+ self.roth += amount
+ self.pretax -= amount
+ self.total_roth_conversions += amount
+ logger.debug(
+ f'Account {self.name} executed pre-tax --> Roth conversion of {amount}'
+ )
+ return amount
+ elif self.pretax > 0:
+ actual_amount = self.pretax
+ self.roth += actual_amount
+ self.pretax = 0
+ self.total_roth_conversions += actual_amount
+ logger.debug(
+ f'Account {self.name} executed pre-tax --> Roth conversion of ' +
+ f'{actual_amount}'
+ )
+ return actual_amount
+ return Money(0)
+
+ def dump_final_report(self):
+ print(f'Account: {self.name}:')
+ print(" %-50s: %18s" % ("Ending balance", self.get_balance()))
+ print(" %-50s: %18s" % ("Total investment gains",
+ self.total_investment_gains))
+ print(" %-50s: %18s" % ("Total Roth withdrawals",
+ self.total_roth_withdrawals))
+ print(" %-50s: %18s" % ("Total pre-tax withdrawals",
+ self.total_pretax_withdrawals))
+ print(" %-50s: %18s" % ("Total pre-tax converted to Roth",
+ self.total_roth_conversions))
+
+
+class AgeRestrictedRothAccount(AgeRestrictedTaxDeferredAccount):
+ """This is an object to represent a Roth account like a Roth IRA. All
+ money in here is tax free. Most of the base account class works here
+ including the implementation of withdraw() which says that none of the
+ money withdrawn was taxable."""
+
+ def __init__(self,
+ name: str,
+ owner: Person,
+ *,
+ total_balance: Money = 0):
+ super().__init__(
+ name,
+ owner,
+ total_balance=total_balance,
+ roth_subbalance=total_balance
+ )
+
+ def has_rmd(self):
+ return False
+
+ def do_rmd_withdrawal(self, owner_age, taxes):
+ raise Exception("This account has no RMDs")
+
+ def do_roth_conversion(self, amount) -> Money:
+ return Money(0)
+
+
+class BrokerageAccount(Account):
+ """A class to represent money in a taxable brokerage account."""
+
+ def __init__(self,
+ name: str,
+ owner: Person,
+ *,
+ total_balance = Money(0),
+ cost_basis = Money(0)):
+ """The c'tor of this class partitions balance into three pieces:
+ the cost_basis (i.e. how much money was invested in the account),
+ the short_term_gain (i.e. appreciation that has been around for
+ less than a year) and long_term_gain (i.e. appreciation that has
+ been around for more than a year). We separate these because
+ taxes on long_term_gain (and qualified dividends, which are not
+ modeled) are usually lower than short_term_gain. Today those
+ are taxed at 15% and as ordinary income, respectively."""
+ super().__init__(name, owner)
+ self.cost_basis = cost_basis
+ self.short_term_gain = Money(0)
+ self.long_term_gain = total_balance - cost_basis
+ self.total_cost_basis_withdrawals = Money(0)
+ self.total_long_term_gain_withdrawals = Money(0)
+ self.total_short_term_gain_withdrawals = Money(0)
+ self.total_investment_gains = Money(0)
+
+ def withdraw(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
+ """Override the base class' withdraw implementation since we're
+ dealing with three piles of money instead of one. When you sell
+ securities to get money out of this account the gains are taxed
+ (and the cost_basis part isn't). Assume that the ratio of
+ cost_basis to overall balance can be used to determine how much
+ of the withdrawal will be taxed (and how)."""
+ balance = self.get_balance()
+ if balance < amount:
+ raise Exception("Insufficient funds")
+
+ if self.cost_basis > 0 and (self.short_term_gain + self.long_term_gain) > 0:
+ return self._withdraw_with_ratio(amount, taxes)
+ else:
+ return self._withdraw_waterfall(amount, taxes)
+
+ def _withdraw_short_term_gain(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
+ if self.short_term_gain >= amount:
+ self.short_term_gain -= amount
+ self.total_short_term_gain_withdrawals += amount
+ if taxes is not None:
+ taxes.record_short_term_gain(amount)
+ return amount
+ raise Exception('Insufficient funds')
+
+ def _withdraw_long_term_gain(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
+ if self.long_term_gain >= amount:
+ self.long_term_gain -= amount
+ self.total_long_term_gain_withdrawals += amount
+ if taxes is not None:
+ taxes.record_dividend_or_long_term_gain(amount)
+ return amount
+ raise Exception('Insufficient funds')
+
+ def _withdraw_cost_basis(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
+ if self.cost_basis >= amount:
+ self.cost_basis -= amount
+ self.total_cost_basis_withdrawals += amount
+ return amount
+ raise Exception('Insufficient funds')
+
+ def _withdraw_with_ratio(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
+ ratio = float(self.cost_basis) / float(self.get_balance())
+ invested_capital_part = amount * ratio
+ invested_capital_part.truncate_fractional_cents()
+ gains_part = amount - invested_capital_part
+ gains_part -= 0.01
+ if self.cost_basis >= invested_capital_part:
+ self._withdraw_cost_basis(invested_capital_part, taxes)
+ logger.debug(
+ f'Account {self.name}: satisfying {invested_capital_part} from cost basis funds.'
+ )
+ logger.debug(
+ f'Account {self.name}: satisfying {gains_part} from investment gains...'
+ )
+ self._withdraw_from_gains(gains_part, taxes)
+ else:
+ logger.debug(
+ f'Account {self.name}: satisfying {gains_part} from investment gains...'
+ )
+ self._withdraw_from_gains(amount, taxes)
+ return amount
+
+ def _withdraw_waterfall(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
+ to_find = amount
+ if self.short_term_gain > 0:
+ if to_find < self.short_term_gain:
+ self.short_term_gain -= to_find
+ self.total_short_term_gain_withdrawals += to_find
+ to_find = Money(0)
+ else:
+ to_find -= self.short_term_gain
+ self.total_short_term_gain_withdrawals += self.short_term_gain
+ self.short_term_gain = Money(0)
+ if self.long_term_gain > 0:
+ if to_find < self.long_term_gain:
+ self.long_term_gain -= to_find
+ self.total_long_term_gain_withdrawals += to_find
+ to_find = Money(0)
+ else:
+ to_find -= self.long_term_gain
+ self.total_long_term_gain_withdrawals += self.long_term_gain
+ self.long_term_gain = Money(0)
+ if self.cost_basis > 0:
+ if to_find < self.cost_basis:
+ self.cost_basis -= to_find
+ to_find = Money(0)
+ else:
+ to_find -= self.cost_basis
+ self.cost_basis = Money(0)
+ assert(to_find == Money(0))
+
+ def _withdraw_from_gains(self, amount: Money, taxes: Optional[TaxCollector]) -> Money:
+ """Withdraw some money from gains. Prefer the long term ones if
+ possible."""
+ to_find = amount
+ if to_find > (self.long_term_gain + self.short_term_gain):
+ raise Exception("Insufficient funds")
+
+ if self.long_term_gain >= to_find:
+ self._withdraw_long_term_gain(to_find, taxes)
+ logger.debug(
+ f'Account {self.name}: satisfying {to_find} from long term gains.'
+ )
+ return to_find
+
+ logger.debug(
+ f'Account {self.name}: satisfying {self.long_term_gain} from long term gains ' +
+ '(exhausting long term gains)'
+ )
+ self._withdraw_long_term_gain(self.long_term_gain, taxes)
+ to_find -= self.long_term_gain
+ self._withdraw_short_term_gain(to_find, taxes)
+ logger.debug(
+ f'Account {self.name}: satisfying {to_find} from short term gains'
+ )
+ return amount
+
+ def get_balance(self) -> Money:
+ """We're ignoring the base class' balance field in favor of tracking
+ it as three separate partitions of money."""
+ return self.cost_basis + self.long_term_gain + self.short_term_gain
+
+ def appreciate(self, rate: float) -> Money:
+ """Appreciate... another year has passed so short_term_gains turn into
+ long_term_gains and the appreciation is our new short_term_gains."""
+ balance = self.get_balance()
+ gain = balance * (rate - 1.0) # Note: rate is something like 1.04
+ self.total_investment_gains += gain
+ self.long_term_gain += self.short_term_gain
+ self.short_term_gain = gain
+ return self.get_balance()
+
+ def is_age_restricted(self) -> bool:
+ return False
+
+ def has_rmd(self) -> bool:
+ return False
+
+ def has_roth(self) -> bool:
+ return False
+
+ def do_roth_conversion(self, amount):
+ return Money(0)
+
+ def dump_final_report(self):
+ print(f'Account {self.name}:')
+ print(" %-50s: %18s" % ("Ending balance", self.get_balance()))
+ print(" %-50s: %18s" % ("Total investment gains",
+ self.total_investment_gains))
+ print(" %-50s: %18s" % ("Total cost basis withdrawals",
+ self.total_cost_basis_withdrawals))
+ print(" %-50s: %18s" % ("Total long term gain withdrawals",
+ self.total_long_term_gain_withdrawals))
+ print(" %-50s: %18s" % ("Total short term gain withdrawals",
+ self.total_short_term_gain_withdrawals))
+++ /dev/null
-import constants
-import utils
-from money import *
-
-class account(object):
- """This defines an account base class which inherits from object, the
- default base class in python. This is not a 100% abstract class, it
- includes some default fields (like balance and name) and methods that
- define behaviors (withdraw, appreciate, etc...). Several subclasses
- are defined below."""
-
- def __init__(self, name, owner):
- """Constructor for account object takes a name and owner"""
- self.name = name
- assert constants.is_valid_owner(owner), "Bad account owner"
- self.owner = owner
-
- def get_name(self):
- return self.name
-
- def get_owner(self):
- return self.owner
-
- def belongs_to_scott(self):
- return self.get_owner() == constants.SCOTT
-
- def belongs_to_lynn(self):
- return self.get_owner() == constants.LYNN
-
- def get_balance(self):
- """Return the current account balance."""
- pass
-
- def appreciate(self, multiplier):
- """Grow the balance using rate."""
- pass
-
- def withdraw(self, amount, taxes):
- """Withdraw money from the account if possible. Throws otherwise.
- Money should be registered with taxes so that we can approximate
- taxes due, too."""
- pass
-
- def deposit(self, amount):
- """Deposit money into the account."""
- pass
-
- def is_age_restricted(self):
- """Is this account age restricted. Subclasses should implement."""
- pass
-
- def has_rmd(self):
- """Does this account have a required minimum distribution? Sub-
- classes should implement."""
- pass
-
- def do_rmd_withdrawal(self, owner_age, taxes):
- """Handle RMD withdrawals for account."""
- pass
-
- def has_roth(self):
- """Does this account have a Roth part?"""
- pass
-
- def dump_final_report(self):
- """Output account-specific info for the final simulation report."""
- print " " + self.name + ":"
-
-class age_restricted_tax_deferred_account(account):
- """Account object used to represent an age-restricted tax deferred
- account such as a 401(k), IRA or annuity."""
-
- def __init__(self, total_balance, roth_amount_subset, name, owner):
- """C'tor for this class takes a roth_amount, the number of dollars
- in the account that are in-plan Roth and can be taken out tax
- free. It keeps this pile separate from the pretax pile and
- tries to estimate taxes."""
- assert total_balance >= 0, "Initial balance must be >= 0"
- assert roth_amount_subset <= total_balance, "Roth subset too high!"
- self.roth = money(roth_amount_subset)
- self.pretax = total_balance - money(roth_amount_subset)
- self.total_roth_withdrawals = money(0)
- self.total_pretax_withdrawals = money(0)
- self.total_investment_gains = money(0)
- self.total_roth_conversions = money(0)
- self.initial_balance = self.get_balance()
- assert self.initial_balance >= 0, "Bad initial balance"
-
- # This calls the super class' c'tor.
- account.__init__(self, name, owner)
-
- # These accounts are all age-restricted
- def is_age_restricted(self):
- return True
-
- def withdraw(self, amount, taxes):
- """Assume that money withdrawn from this account will be a mixture
- of pretax funds (which count as ordinary income) and Roth funds
- (which are available tax-free). Assume that the ratio of pretax
- to Roth in this overall account determines the amount from each
- partition in this withdrawal."""
- if amount <= 0: return 0
- balance = self.get_balance()
- if balance < amount: raise Exception("Insufficient funds")
-
- ratio = self.roth / balance
- roth_part = amount * ratio
- pretax_part = amount - roth_part
- if roth_part > 0:
- self.roth -= roth_part
- self.total_roth_withdrawals += roth_part
- print "## Satisfying %s from %s with Roth money." % (roth_part,
- self.name)
- taxes.record_roth_income(roth_part)
- if pretax_part > 0:
- self.pretax -= pretax_part
- self.total_pretax_withdrawals += pretax_part
- print "## Satisfying %s from %s with pre-tax money." % (pretax_part,
- self.name)
- taxes.record_ordinary_income(pretax_part)
-
- def deposit(self, amount):
- self.pretax += amount
-
- def appreciate(self, multiplier):
- old_pretax = self.pretax
- self.pretax *= multiplier
- self.total_investment_gains += self.pretax - old_pretax
- old_roth = self.roth
- self.roth *= multiplier
- self.total_investment_gains += self.roth - old_roth
-
- def get_balance(self):
- """In this class we keep the balance in two parts."""
- return self.pretax + self.roth
-
- def has_rmd(self):
- return True
-
- # Used to compute RMDs. Source:
- # https://www.irs.gov/publications/p590b#en_US_2018_publink1000231258
- def get_actuary_number_years_to_live(self, age):
- if age < 70:
- return 27.4 + age
- elif age == 70:
- return 27.4
- elif age == 71:
- return 26.5
- elif age == 72:
- return 25.6
- elif age == 73:
- return 24.7
- elif age == 74:
- return 23.8
- elif age == 75:
- return 22.9
- elif age == 76:
- return 22.0
- elif age == 77:
- return 21.2
- elif age == 78:
- return 20.3
- elif age == 79:
- return 19.5
- elif age == 80:
- return 18.7
- elif age == 81:
- return 17.9
- elif age == 82:
- return 17.1
- elif age == 83:
- return 16.3
- elif age == 84:
- return 15.5
- elif age == 85:
- return 14.8
- elif age == 86:
- return 14.1
- elif age == 87:
- return 13.4
- elif age == 88:
- return 12.7
- elif age == 89:
- return 12.0
- elif age == 90:
- return 11.4
- elif age == 91:
- return 10.8
- elif age == 92:
- return 10.2
- elif age == 93:
- return 9.6
- elif age == 94:
- return 9.1
- elif age == 95:
- return 8.6
- elif age == 96:
- return 8.1
- elif age == 97:
- return 7.6
- elif age == 98:
- return 7.1
- elif age == 99:
- return 6.7
- elif age == 100:
- return 6.3
- elif age == 101:
- return 5.9
- elif age == 102:
- return 5.5
- elif age == 103:
- return 5.2
- elif age == 104:
- return 4.9
- elif age == 105:
- return 4.5
- elif age == 106:
- return 4.2
- elif age == 107:
- return 3.9
- elif age == 108:
- return 3.7
- elif age == 109:
- return 3.4
- elif age == 110:
- return 3.1
- elif age == 111:
- return 2.9
- elif age == 112:
- return 2.6
- elif age == 113:
- return 2.4
- elif age == 114:
- return 2.1
- else:
- return 1.9
-
- def do_rmd_withdrawal(self, owner_age, taxes):
- balance = self.get_balance()
- if balance > 0 and owner_age >= 72:
- rmd_factor = self.get_actuary_number_years_to_live(owner_age)
- amount = balance / rmd_factor
- self.withdraw(amount, taxes)
- return amount
- return 0
-
- def has_roth(self):
- return True
-
- def do_roth_conversion(self, amount, taxes):
- if amount <= 0: return 0
- amount = min(amount, self.pretax)
- self.roth += amount
- self.pretax -= amount
- assert self.pretax >= 0, "Somehow exhausted more than all pretax money"
- self.total_roth_conversions += amount
- taxes.record_ordinary_income(amount)
- print "## Executed pre-tax --> Roth conversion of %s in %s" % (
- amount, self.get_name())
- return amount
-
- def dump_final_report(self):
- super(age_restricted_tax_deferred_account, self).dump_final_report()
- print " {:<50}: {:>14}".format("Initial balance",
- self.initial_balance)
- print " {:<50}: {:>14}".format("Ending balance",
- self.get_balance())
- print " {:<50}: {:>14}".format("Total investment gains",
- self.total_investment_gains)
- print " {:<50}: {:>14}".format("Total Roth withdrawals",
- self.total_roth_withdrawals)
- print " {:<50}: {:>14}".format("Total pre-tax withdrawals",
- self.total_pretax_withdrawals)
- print " {:<50}: {:>14}".format("Total pre-tax converted to Roth",
- self.total_roth_conversions)
-
-class age_restricted_roth_account(age_restricted_tax_deferred_account):
- """This is an object to represent a Roth account like a Roth IRA. All
- money in here is tax free. Most of the base account class works here
- including the implementation of withdraw() which says that none of the
- money withdrawn was taxable."""
-
- def __init__(self, total_balance, name, owner):
- """C'tor for this class takes a roth_amount, the number of dollars
- in the account that are in-plan Roth and can be taken out tax
- free. It keeps this pile separate from the pretax pile and
- tries to estimate taxes."""
- assert total_balance >= 0, "Initial balance must be >= 0"
- age_restricted_tax_deferred_account.__init__(
- self, total_balance, total_balance, name, owner)
-
- # Override
- def has_rmd(self):
- return False
-
- # Override
- def do_rmd_withdrawal(self, owner_age, taxes):
- raise Exception("This account has no RMDs")
-
- # Override
- def do_roth_conversion(self, amount, taxes):
- return 0
-
-class brokerage_account(account):
- """A class to represent money in a taxable brokerage account."""
-
- def __init__(self, total_balance, cost_basis, name, owner):
- """The c'tor of this class partitions balance into three pieces:
- the cost_basis (i.e. how much money was invested in the account),
- the short_term_gain (i.e. appreciation that has been around for
- less than a year) and long_term_gain (i.e. appreciation that has
- been around for more than a year). We separate these because
- taxes on long_term_gain (and qualified dividends, which are not
- modeled) are usually lower than short_term_gain. Today those
- are taxed at 15% and as ordinary income, respectively."""
- assert total_balance >= 0, "Initial balance must be >= 0"
- assert cost_basis <= total_balance, "Bad initial cost basis"
- self.cost_basis = money(cost_basis)
- self.short_term_gain = money(0)
- self.long_term_gain = money(total_balance - cost_basis)
- self.total_cost_basis_withdrawals = money(0)
- self.total_long_term_gain_withdrawals = money(0)
- self.total_short_term_gain_withdrawals = money(0)
- self.total_investment_gains = money(0)
- self.initial_balance = self.get_balance()
-
- # Call the base class' c'tor as well to set up the name.
- account.__init__(self, name, owner)
-
- def withdraw(self, amount, taxes):
- """Override the base class' withdraw implementation since we're
- dealing with three piles of money instead of one. When you sell
- securities to get money out of this account the gains are taxed
- (and the cost_basis part isn't). Assume that the ratio of
- cost_basis to overall balance can be used to determine how much
- of the withdrawal will be taxed (and how)."""
- if amount <= 0: return 0
- balance = self.get_balance()
- if balance < amount: raise Exception("Insufficient funds")
-
- ratio = self.cost_basis / balance
- invested_capital_part = amount * ratio
- gains_part = amount - invested_capital_part
-
- if self.cost_basis >= invested_capital_part:
- self.cost_basis -= invested_capital_part
- self.total_cost_basis_withdrawals += invested_capital_part
- print "## Satisfying %s from %s as cost basis." % (invested_capital_part,
- self.name)
- self.withdraw_from_gains(gains_part, taxes)
- else:
- self.withdraw_from_gains(amount, taxes)
-
- def withdraw_from_gains(self, amount, taxes):
- """Withdraw some money from gains. Prefer the long term ones if
- possible."""
- if amount <= 0: return 0
- if amount > (self.long_term_gain + self.short_term_gain):
- raise Exception("Insufficient funds")
-
- if self.long_term_gain >= amount:
- self.long_term_gain -= amount
- self.total_long_term_gain_withdrawals += amount
- print "## Satisfying %s from %s as long term gains." % (
- amount, self.name)
- taxes.record_dividend_or_long_term_gain(amount)
- return
-
- else:
- print "## Satisfying %s from %s as long term gains (exhausting all current long term gains in account)." % (
- self.long_term_gain, self.name)
- amount -= self.long_term_gain
- self.total_long_term_gain_withdrawals += self.long_term_gain
- taxes.record_dividend_or_long_term_gain(self.long_term_gain)
- self.long_term_gain = 0
- # Get the rest of amount from short term gains...
-
- self.short_term_gain -= amount
- self.total_short_term_gain_withdrawals += amount
- print "## Satisfying %s from %s as short term gains." % (
- amount, self.name)
- taxes.record_short_term_gain(amount)
-
- def deposit(self, amount):
- assert amount >= 0, "Can't deposit negative amounts"
- self.cost_basis += amount
-
- def get_balance(self):
- """We're ignoring the base class' balance field in favor of tracking
- it as three separate partitions of money."""
- return self.cost_basis + self.long_term_gain + self.short_term_gain
-
- def appreciate(self, multiplier):
- """Appreciate... another year has passed so short_term_gains turn into
- long_term_gains and the appreciation is our new short_term_gains."""
- balance = self.get_balance()
- balance *= multiplier
- gain_or_loss = balance - self.get_balance()
- self.total_investment_gains += gain_or_loss
- self.long_term_gain += self.short_term_gain
- self.short_term_gain = gain_or_loss
-
- def is_age_restricted(self):
- return False
-
- def has_rmd(self):
- return False
-
- def has_roth(self):
- return False
-
- def do_roth_conversion(self, amount, taxes):
- return 0
-
- def dump_final_report(self):
- super(brokerage_account, self).dump_final_report()
- print " {:<50}: {:>14}".format("Initial balance",
- self.initial_balance)
- print " {:<50}: {:>14}".format("Ending balance",
- self.get_balance())
- print " {:<50}: {:>14}".format("Total investment gains",
- self.total_investment_gains)
- print " {:<50}: {:>14}".format("Total cost basis withdrawals",
- self.total_cost_basis_withdrawals)
- print " {:<50}: {:>14}".format("Total long term gain withdrawals",
- self.total_long_term_gain_withdrawals)
- print " {:<50}: {:>14}".format("Total short term gain withdrawals",
- self.total_short_term_gain_withdrawals)
+++ /dev/null
-from money import money
-
-# Consts
-DEFAULT = 0
-SCOTT = 1
-LYNN = 2
-
-def is_valid_owner(owner):
- """Is an owner valid?"""
- return (owner >= DEFAULT and owner <= LYNN)
-
-PESSIMISTIC_FEDERAL_INCOME_TAX_BRACKETS = [
- [ money(612351), 0.50 ],
- [ money(408201), 0.45 ],
- [ money(321451), 0.35 ],
- [ money(168401), 0.25 ],
- [ money( 78951), 0.22 ],
- [ money( 19401), 0.15 ],
- [ money( 1), 0.12 ]
-]
-CURRENT_FEDERAL_INCOME_TAX_BRACKETS = [
- [ money(612351), 0.37 ],
- [ money(408201), 0.35 ],
- [ money(321451), 0.32 ],
- [ money(168401), 0.24 ],
- [ money( 78951), 0.22 ],
- [ money( 19401), 0.12 ],
- [ money( 1), 0.10 ]
-]
-CURRENT_LONG_TERM_GAIN_FEDERAL_TAX_BRACKETS = [
- [ money(488851), 0.20 ],
- [ money( 78751), 0.15 ],
- [ money( 1), 0.00 ]
-]
-PESSIMISTIC_LONG_TERM_GAIN_FEDERAL_TAX_BRACKETS = (
- PESSIMISTIC_FEDERAL_INCOME_TAX_BRACKETS)
--- /dev/null
+#!/usr/bin/env python3
+
+from dataclasses import dataclass
+from typing import Dict
+
+from type.rate import Rate
+
+
+# Used to compute RMDs. Source:
+# https://www.irs.gov/publications/p590b#en_US_2018_publink1000231258
+IRS_ACTUARY_TABLES = {
+ 70: 27.4,
+ 71: 26.5,
+ 72: 25.6,
+ 73: 24.7,
+ 74: 23.8,
+ 75: 22.9,
+ 76: 22.0,
+ 77: 21.2,
+ 78: 20.3,
+ 79: 19.5,
+ 80: 18.7,
+ 81: 17.9,
+ 82: 17.1,
+ 83: 16.3,
+ 84: 15.5,
+ 85: 14.8,
+ 86: 14.1,
+ 87: 13.4,
+ 88: 12.7,
+ 89: 12.0,
+ 90: 11.4,
+ 91: 10.8,
+ 92: 10.2,
+ 93: 9.6,
+ 94: 9.1,
+ 95: 8.6,
+ 96: 8.1,
+ 97: 7.6,
+ 98: 7.1,
+ 99: 6.7,
+ 100: 6.3,
+ 101: 5.9,
+ 102: 5.5,
+ 103: 5.2,
+ 104: 4.9,
+ 105: 4.5,
+ 106: 4.2,
+ 107: 3.9,
+ 108: 3.7,
+ 109: 3.4,
+ 110: 3.1,
+ 111: 2.9,
+ 112: 2.6,
+ 113: 2.4,
+ 114: 2.1,
+}
+
+
+def get_actuary_number_years_to_live(age: int):
+ if age < 70:
+ return 27.4 + age
+ if age > 114:
+ return 1.9
+ return IRS_ACTUARY_TABLES[age]
+
+
+def make_multiplier(percent: float) -> Rate:
+ percent /= 100
+ if percent > 0:
+ percent += 1.0
+ else:
+ percent = 1.0 - percent
+ return percent
+
+
+# year s&p 10y infla-
+# 500 bond tion
+#
+# source: https://www.multpl.com/inflation/table/by-year
+# source: http://pages.stern.nyu.edu/~adamodar/New_Home_Page/datafile/histretSP.html
+
+@dataclass
+class StocksBondsAndInflation:
+ snp_500_return: Rate
+ us_10y_bond_return: Rate
+ inflation: Rate
+
+ def __init__(self, snp: Rate, bonds: Rate, inflation: Rate):
+ self.snp_500_return = snp
+ self.us_10y_bond_return = bonds
+ self.inflation = inflation
+
+
+ROIs_AND_INFLATION: Dict[int, StocksBondsAndInflation] = {
+ 2020: StocksBondsAndInflation(Rate(percent_change=18.01), Rate(percent_change=11.33), Rate(percent_change=2.49)),
+ 2019: StocksBondsAndInflation(Rate(percent_change=31.21), Rate(percent_change=9.64), Rate(percent_change=1.55)),
+ 2018: StocksBondsAndInflation(Rate(percent_change=-4.23), Rate(percent_change=-0.02), Rate(percent_change=2.07)),
+ 2017: StocksBondsAndInflation(Rate(percent_change=21.61), Rate(percent_change=2.80), Rate(percent_change=2.50)),
+ 2016: StocksBondsAndInflation(Rate(percent_change=11.77), Rate(percent_change=0.69), Rate(percent_change=1.37)),
+ 2015: StocksBondsAndInflation(Rate(percent_change=1.38), Rate(percent_change=1.28), Rate(percent_change=-0.09)),
+ 2014: StocksBondsAndInflation(Rate(percent_change=13.52), Rate(percent_change=10.75), Rate(percent_change=1.58)),
+ 2013: StocksBondsAndInflation(Rate(percent_change=32.15), Rate(percent_change=-9.10), Rate(percent_change=1.59)),
+ 2012: StocksBondsAndInflation(Rate(percent_change=15.89), Rate(percent_change=2.97), Rate(percent_change=2.93)),
+ 2011: StocksBondsAndInflation(Rate(percent_change=2.10), Rate(percent_change=16.04), Rate(percent_change=1.63)),
+ 2010: StocksBondsAndInflation(Rate(percent_change=14.82), Rate(percent_change=8.46), Rate(percent_change=2.63)),
+ 2009: StocksBondsAndInflation(Rate(percent_change=25.94), Rate(percent_change=-11.12), Rate(percent_change=0.03)),
+ 2008: StocksBondsAndInflation(Rate(percent_change=-36.55), Rate(percent_change=20.10), Rate(percent_change=4.28)),
+ 2007: StocksBondsAndInflation(Rate(percent_change=5.48), Rate(percent_change=10.21), Rate(percent_change=2.08)),
+ 2006: StocksBondsAndInflation(Rate(percent_change=15.61), Rate(percent_change=1.96), Rate(percent_change=3.99)),
+ 2005: StocksBondsAndInflation(Rate(percent_change=4.83), Rate(percent_change=2.87), Rate(percent_change=2.97)),
+ 2004: StocksBondsAndInflation(Rate(percent_change=10.74), Rate(percent_change=4.49), Rate(percent_change=1.93)),
+ 2003: StocksBondsAndInflation(Rate(percent_change=28.36), Rate(percent_change=0.38), Rate(percent_change=2.60)),
+ 2002: StocksBondsAndInflation(Rate(percent_change=-21.97), Rate(percent_change=15.12), Rate(percent_change=1.14)),
+ 2001: StocksBondsAndInflation(Rate(percent_change=-11.85), Rate(percent_change=5.57), Rate(percent_change=3.73)),
+ 2000: StocksBondsAndInflation(Rate(percent_change=-9.03), Rate(percent_change=16.66), Rate(percent_change=2.74)),
+ 1999: StocksBondsAndInflation(Rate(percent_change=20.89), Rate(percent_change=-8.25), Rate(percent_change=1.67)),
+ 1998: StocksBondsAndInflation(Rate(percent_change=28.34), Rate(percent_change=14.92), Rate(percent_change=1.57)),
+ 1997: StocksBondsAndInflation(Rate(percent_change=33.10), Rate(percent_change=9.94), Rate(percent_change=3.04)),
+ 1996: StocksBondsAndInflation(Rate(percent_change=22.68), Rate(percent_change=1.43), Rate(percent_change=2.73)),
+ 1995: StocksBondsAndInflation(Rate(percent_change=37.20), Rate(percent_change=23.48), Rate(percent_change=2.80)),
+ 1994: StocksBondsAndInflation(Rate(percent_change=1.33), Rate(percent_change=-8.04), Rate(percent_change=2.52)),
+ 1993: StocksBondsAndInflation(Rate(percent_change=9.97), Rate(percent_change=14.21), Rate(percent_change=3.26)),
+ 1992: StocksBondsAndInflation(Rate(percent_change=7.49), Rate(percent_change=9.36), Rate(percent_change=2.60)),
+ 1991: StocksBondsAndInflation(Rate(percent_change=30.23), Rate(percent_change=15.00), Rate(percent_change=5.65)),
+ 1990: StocksBondsAndInflation(Rate(percent_change=-3.06), Rate(percent_change=6.24), Rate(percent_change=5.20)),
+ 1989: StocksBondsAndInflation(Rate(percent_change=31.48), Rate(percent_change=17.69), Rate(percent_change=4.67)),
+ 1988: StocksBondsAndInflation(Rate(percent_change=16.54), Rate(percent_change=8.22), Rate(percent_change=4.05)),
+ 1987: StocksBondsAndInflation(Rate(percent_change=5.81), Rate(percent_change=-4.96), Rate(percent_change=1.46)),
+ 1986: StocksBondsAndInflation(Rate(percent_change=18.49), Rate(percent_change=24.28), Rate(percent_change=3.89)),
+ 1985: StocksBondsAndInflation(Rate(percent_change=31.24), Rate(percent_change=25.71), Rate(percent_change=3.53)),
+ 1984: StocksBondsAndInflation(Rate(percent_change=6.15), Rate(percent_change=13.73), Rate(percent_change=4.19)),
+ 1983: StocksBondsAndInflation(Rate(percent_change=22.34), Rate(percent_change=3.20), Rate(percent_change=3.71)),
+ 1982: StocksBondsAndInflation(Rate(percent_change=20.42), Rate(percent_change=32.81), Rate(percent_change=8.39)),
+ 1981: StocksBondsAndInflation(Rate(percent_change=-4.70), Rate(percent_change=8.20), Rate(percent_change=11.83)),
+ 1980: StocksBondsAndInflation(Rate(percent_change=31.74), Rate(percent_change=-2.99), Rate(percent_change=13.91)),
+ 1979: StocksBondsAndInflation(Rate(percent_change=18.52), Rate(percent_change=0.67), Rate(percent_change=9.28)),
+ 1978: StocksBondsAndInflation(Rate(percent_change=6.51), Rate(percent_change=-0.78), Rate(percent_change=6.84)),
+ 1977: StocksBondsAndInflation(Rate(percent_change=-6.98), Rate(percent_change=1.29), Rate(percent_change=5.22)),
+ 1976: StocksBondsAndInflation(Rate(percent_change=23.83), Rate(percent_change=15.98), Rate(percent_change=6.72)),
+ 1975: StocksBondsAndInflation(Rate(percent_change=37.00), Rate(percent_change=3.61), Rate(percent_change=11.80)),
+ 1974: StocksBondsAndInflation(Rate(percent_change=-25.90), Rate(percent_change=1.99), Rate(percent_change=9.39)),
+ 1973: StocksBondsAndInflation(Rate(percent_change=-14.31), Rate(percent_change=3.66), Rate(percent_change=3.65)),
+ 1972: StocksBondsAndInflation(Rate(percent_change=18.76), Rate(percent_change=2.82), Rate(percent_change=3.27)),
+ 1971: StocksBondsAndInflation(Rate(percent_change=14.22), Rate(percent_change=9.79), Rate(percent_change=5.29)),
+ 1970: StocksBondsAndInflation(Rate(percent_change=3.56), Rate(percent_change=16.75), Rate(percent_change=6.18)),
+ 1969: StocksBondsAndInflation(Rate(percent_change=-8.24), Rate(percent_change=-5.01), Rate(percent_change=4.40)),
+ 1968: StocksBondsAndInflation(Rate(percent_change=10.81), Rate(percent_change=3.27), Rate(percent_change=3.65)),
+ 1967: StocksBondsAndInflation(Rate(percent_change=23.80), Rate(percent_change=-1.58), Rate(percent_change=3.46)),
+ 1966: StocksBondsAndInflation(Rate(percent_change=-9.97), Rate(percent_change=2.91), Rate(percent_change=1.92)),
+ 1965: StocksBondsAndInflation(Rate(percent_change=12.40), Rate(percent_change=0.72), Rate(percent_change=0.97)),
+ 1964: StocksBondsAndInflation(Rate(percent_change=16.42), Rate(percent_change=3.73), Rate(percent_change=1.64)),
+ 1963: StocksBondsAndInflation(Rate(percent_change=22.61), Rate(percent_change=1.68), Rate(percent_change=1.33)),
+ 1962: StocksBondsAndInflation(Rate(percent_change=-8.81), Rate(percent_change=5.69), Rate(percent_change=0.67)),
+ 1961: StocksBondsAndInflation(Rate(percent_change=26.64), Rate(percent_change=2.06), Rate(percent_change=1.71)),
+ 1960: StocksBondsAndInflation(Rate(percent_change=0.34), Rate(percent_change=11.64), Rate(percent_change=1.03)),
+ 1959: StocksBondsAndInflation(Rate(percent_change=12.06), Rate(percent_change=-2.65), Rate(percent_change=1.40)),
+ 1958: StocksBondsAndInflation(Rate(percent_change=43.72), Rate(percent_change=-2.10), Rate(percent_change=3.62)),
+ 1957: StocksBondsAndInflation(Rate(percent_change=-10.46), Rate(percent_change=6.80), Rate(percent_change=2.99)),
+ 1956: StocksBondsAndInflation(Rate(percent_change=7.44), Rate(percent_change=-2.26), Rate(percent_change=0.37)),
+ 1955: StocksBondsAndInflation(Rate(percent_change=32.60), Rate(percent_change=-1.34), Rate(percent_change=-0.74)),
+ 1954: StocksBondsAndInflation(Rate(percent_change=52.56), Rate(percent_change=3.29), Rate(percent_change=1.13)),
+ 1953: StocksBondsAndInflation(Rate(percent_change=-1.21), Rate(percent_change=4.14), Rate(percent_change=0.38)),
+ 1952: StocksBondsAndInflation(Rate(percent_change=18.15), Rate(percent_change=2.27), Rate(percent_change=4.33)),
+ 1951: StocksBondsAndInflation(Rate(percent_change=23.68), Rate(percent_change=-0.30), Rate(percent_change=8.09)),
+ 1950: StocksBondsAndInflation(Rate(percent_change=30.81), Rate(percent_change=0.43), Rate(percent_change=-2.08)),
+ 1949: StocksBondsAndInflation(Rate(percent_change=18.30), Rate(percent_change=4.66), Rate(percent_change=1.27)),
+ 1948: StocksBondsAndInflation(Rate(percent_change=5.70), Rate(percent_change=1.95), Rate(percent_change=10.23)),
+ 1947: StocksBondsAndInflation(Rate(percent_change=5.20), Rate(percent_change=0.92), Rate(percent_change=18.13)),
+ 1946: StocksBondsAndInflation(Rate(percent_change=-8.43), Rate(percent_change=3.13), Rate(percent_change=2.25)),
+ 1945: StocksBondsAndInflation(Rate(percent_change=35.82), Rate(percent_change=3.80), Rate(percent_change=2.30)),
+ 1944: StocksBondsAndInflation(Rate(percent_change=19.03), Rate(percent_change=2.58), Rate(percent_change=2.96)),
+ 1943: StocksBondsAndInflation(Rate(percent_change=25.06), Rate(percent_change=2.49), Rate(percent_change=7.64)),
+ 1942: StocksBondsAndInflation(Rate(percent_change=19.17), Rate(percent_change=2.29), Rate(percent_change=11.35)),
+ 1941: StocksBondsAndInflation(Rate(percent_change=-12.77), Rate(percent_change=-2.02), Rate(percent_change=1.44)),
+ 1940: StocksBondsAndInflation(Rate(percent_change=-10.67), Rate(percent_change=5.40), Rate(percent_change=-0.71)),
+ 1939: StocksBondsAndInflation(Rate(percent_change=-1.10), Rate(percent_change=4.41), Rate(percent_change=-1.41)),
+ 1938: StocksBondsAndInflation(Rate(percent_change=29.28), Rate(percent_change=4.21), Rate(percent_change=0.71)),
+ 1937: StocksBondsAndInflation(Rate(percent_change=-35.34), Rate(percent_change=1.38), Rate(percent_change=2.17)),
+ 1936: StocksBondsAndInflation(Rate(percent_change=31.94), Rate(percent_change=5.02), Rate(percent_change=1.47)),
+ 1935: StocksBondsAndInflation(Rate(percent_change=46.74), Rate(percent_change=4.47), Rate(percent_change=3.03)),
+ 1934: StocksBondsAndInflation(Rate(percent_change=-1.19), Rate(percent_change=7.96), Rate(percent_change=2.33)),
+ 1933: StocksBondsAndInflation(Rate(percent_change=49.98), Rate(percent_change=1.86), Rate(percent_change=-9.79)),
+ 1932: StocksBondsAndInflation(Rate(percent_change=-8.64), Rate(percent_change=8.79), Rate(percent_change=-10.06)),
+ 1931: StocksBondsAndInflation(Rate(percent_change=-43.84), Rate(percent_change=-2.56), Rate(percent_change=-7.02)),
+ 1930: StocksBondsAndInflation(Rate(percent_change=-25.12), Rate(percent_change=4.54), Rate(percent_change=0.00)),
+ 1929: StocksBondsAndInflation(Rate(percent_change=-8.30), Rate(percent_change=4.20), Rate(percent_change=-1.16)),
+ 1928: StocksBondsAndInflation(Rate(percent_change=43.81), Rate(percent_change=0.84), Rate(percent_change=-1.14)),
+}
+++ /dev/null
-import decimal
-
-class money(object):
- def __init__(self, amount="0"):
- try:
- if isinstance(amount, money):
- amount = amount.amount
- self.amount = decimal.Decimal(amount)
- except decimal.InvalidOperation:
- raise ValueError("amount value could not be converted to "
- "Decimal(): '{}'".format(amount))
-
- def amount(self):
- return self.amount
-
- def __hash__(self):
- return hash(self.amount)
-
- def __repr__(self):
- return "{}".format(self.amount)
-
- def __str__(self):
- return self.__unicode__().encode('utf-8')
-
- def __unicode__(self):
- return u"${:,.2f}".format(self.amount)
-
- def __lt__(self, other):
- if not isinstance(other, money):
- other = money(other)
- return self.amount < other.amount
-
- def __le__(self, other):
- if not isinstance(other, money):
- other = money(other)
- return self.amount <= other.amount
-
- def __eq__(self, other):
- if isinstance(other, money):
- return self.amount == other.amount
- return False
-
- def __ne__(self, other):
- return not self == other
-
- def __gt__(self, other):
- if not isinstance(other, money):
- other = money(other)
- return self.amount > other.amount
-
- def __ge__(self, other):
- if not isinstance(other, money):
- other = money(other)
- return self.amount >= other.amount
-
- def __add__(self, other):
- if not isinstance(other, money):
- other = money(other)
- other = other.amount
- amount = self.amount + other
- return self.__class__(amount)
-
- def __radd__(self, other):
- return self.__add__(other)
-
- def __sub__(self, other):
- if not isinstance(other, money):
- other = money(other)
- other = other.amount
- amount = self.amount - other
- return self.__class__(amount)
-
- def __rsub__(self, other):
- return (-self).__add__(other)
-
- def __mul__(self, other):
- if isinstance(other, money):
- other = other.amount()
- amount = self.amount * decimal.Decimal(other)
- return self.__class__(amount)
-
- def __rmul__(self, other):
- return self.__mul__(other)
-
- def __div__(self, other):
- return self.__truediv__(other)
-
- def __truediv__(self, other):
- if isinstance(other, money):
- if other.amount == 0:
- raise ZeroDivisionError()
- return self.amount / other.amount
- else:
- if other == 0:
- raise ZeroDivisionError()
- amount = self.amount / decimal.Decimal(other)
- return self.__class__(amount)
-
- def __floordiv__(self, other):
- if isinstance(other, money):
- if other.amount == 0:
- raise ZeroDivisionError()
- return self.amount // other.amount
- else:
- if other == 0:
- raise ZeroDivisionError()
- amount = self.amount // other
- return self.__class__(amount)
-
- def __mod__(self, other):
- if isinstance(other, money):
- raise TypeError("modulo is unsupported between two '{}' "
- "objects".format(self.__class__.__name__))
- if other == 0:
- raise ZeroDivisionError()
- amount = self.amount % other
- return self.__class__(amount)
-
- def __divmod__(self, other):
- if isinstance(other, money):
- if other.amount == 0:
- raise ZeroDivisionError()
- return divmod(self.amount, other.amount)
- else:
- if other == 0:
- raise ZeroDivisionError()
- whole, remainder = divmod(self.amount, other)
- return (self.__class__(whole),
- self.__class__(remainder))
-
- def __pow__(self, other):
- if isinstance(other, money):
- raise TypeError("power operator is unsupported between two '{}' "
- "objects".format(self.__class__.__name__))
- amount = self.amount ** other
- return self.__class__(amount)
-
- def __neg__(self):
- return self.__class__(-self.amount)
-
- def __pos__(self):
- return self.__class__(+self.amount)
-
- def __abs__(self):
- return self.__class__(abs(self.amount))
-
- def __int__(self):
- return int(self.amount)
-
- def __float__(self):
- return float(self.amount)
-
- def __round__(self, ndigits=0):
- return self.__class__(round(self.amount, ndigits))
+++ /dev/null
-import constants
-import utils
-from tax_brackets import tax_brackets
-from money import money
-from numpy import random
-
-class parameters(object):
- def get_initial_annual_expenses(self):
- pass
-
- def get_average_inflation_multiplier(self):
- pass
-
- def get_average_social_security_multiplier(self):
- pass
-
- def get_average_investment_return_multiplier(self):
- pass
-
- def get_initial_social_security_age(self, person):
- pass
-
- def get_initial_social_security_benefit(self, person):
- pass
-
- def get_federal_standard_deduction(self):
- pass
-
- def get_federal_ordinary_income_tax_brackets(self):
- pass
-
- def get_federal_dividends_and_long_term_gains_income_tax_brackets(self):
- pass
-
- def dump(self):
- pass
-
- def report_year(self, year):
- pass
-
-class mutable_default_parameters(parameters):
- """A container to hold the initial states of several simulation
- parameters. Play with them as you see fit and see what happens."""
-
- def __init__(self):
- self.with_default_values()
-
- def with_default_values(self):
- # Annual expenses in USD at the start of the simulation. This
- # will be adjusted upwards with inflation_multiplier every year.
- self.initial_annual_expenses = money(144300)
-
- # The average US inflation rate during the simulation. The
- # Fed's target rate is 2.0% as of 2020. The long term observed
- # average historical inflation rate in the US is 2.1%.
- #
- # Note this is a multiplier... so 1.0 would be no inflation,
- # 1.21 is 2.1% inflation, 0.9 is deflation, etc...
- self.inflation_multiplier = 1.03
-
- # We want to be able to model social security payments not
- # keeping pace with inflation. Like the inflation_multiplier
- # above, this is a multiplier. It affect the magnitide of
- # social security payments year over year.
- self.social_security_multiplier = 1.02
-
- # This is the average investment return rate. Asset allocation has
- # a large effect on this as does the overall economy. That said,
- # the US stock market has returned 10%/year over a large enough
- # time window. Our investments at Fidelity have returned 6.91%
- # in the lifetime they have been there. The expected return of a
- # 50/50 stock/bond investment mix based on historical data is 8.3%.
- self.investment_multiplier = 1.04
-
- # The age at which each person will take social security
- # benefits in this simulation and how much she will get the
- # first year. Note, this benefit size increases year over
- # year at social_security_multiplier. This is what the social
- # security website estimates if we both earn $0 from 2020 on:
- #
- # Lynn's benefits: Scott's benefits:
- # age 62 - $21,120 age 62 - $21,420
- # age 67 - $30,000 age 67 - $30,420
- # age 70 - $37,200 age 70 - $37,728
- #
- # X SCOTT LYNN
- self.social_security_age = [0, 62, 62 ]
- self.initial_social_security_dollars = [0, money(21000), money(21000) ]
-
- # Tax details... the standard deduction amount and tax
- # brackets for ordinary income and long term capital gains.
- self.federal_standard_deduction_dollars = 24800
- self.federal_ordinary_income_tax_brackets = tax_brackets(
- constants.PESSIMISTIC_FEDERAL_INCOME_TAX_BRACKETS)
- self.federal_dividends_and_long_term_gains_brackets = tax_brackets(
- constants.CURRENT_LONG_TERM_GAIN_FEDERAL_TAX_BRACKETS)
- return self
-
- def set_initial_annual_expenses(self, expenses):
- assert expenses >= 0, "You can't have negative expenses"
- self.initial_annual_expenses = expenses
- return self
-
- def get_initial_annual_expenses(self):
- return self.initial_annual_expenses
-
- def set_average_inflation_multiplier(self, multiplier):
- self.inflation_multiplier = multiplier
- return self
-
- def get_average_inflation_multiplier(self):
- return self.inflation_multiplier
-
- def set_average_social_security_multiplier(self, multiplier):
- self.social_security_multiplier = multiplier
- return self
-
- def get_average_social_security_multiplier(self):
- return self.social_security_multiplier
-
- def set_average_investment_return_multiplier(self, multiplier):
- self.investment_multiplier = multiplier
- return self
-
- def get_average_investment_return_multiplier(self):
- return self.investment_multiplier
-
- def set_initial_social_security_age_and_benefits(self,
- person,
- age,
- amount):
- assert age >= 60 and age <= 70, "age should be between 60 and 70"
- self.social_security_age[person] = age
- assert amount >= 0, "Social security won't pay negative dollars"
- self.initial_social_security_dollars[person] = amount
- return self
-
- def get_initial_social_security_age(self, person):
- return self.social_security_age[person]
-
- def get_initial_social_security_benefit(self, person):
- return self.initial_social_security_dollars[person]
-
- def set_federal_standard_deduction(self, deduction):
- assert deduction >= 0, "Standard deduction should be non-negative"
- self.federal_standard_deduction_dollars = deduction
- return self
-
- def get_federal_standard_deduction(self):
- return self.federal_standard_deduction_dollars
-
- def set_federal_ordinary_income_tax_brackets(self, brackets):
- self.federal_ordinary_income_tax_brackets = brackets
- return self
-
- def get_federal_ordinary_income_tax_brackets(self):
- return self.federal_ordinary_income_tax_brackets
-
- def set_federal_dividends_and_long_term_gains_income_tax_brackets(self,
- brackets):
- self.federal_dividends_and_long_term_gains_brackets = brackets;
- return self
-
- def get_federal_dividends_and_long_term_gains_income_tax_brackets(self):
- return self.federal_dividends_and_long_term_gains_brackets
-
- def dump(self):
- print "SIMULATION PARAMETERS"
- print " {:<50}: {:>14}".format("Initial year annual expenses",
- self.initial_annual_expenses)
- print " {:<50}: {:>14}".format("Annual inflation rate",
- utils.format_rate(self.inflation_multiplier))
- print " {:<50}: {:>14}".format("Average annual investment return rate",
- utils.format_rate(self.investment_multiplier))
- print " {:<50}: {:>14}".format("Annual social security benefit increase rate",
- utils.format_rate(self.social_security_multiplier))
- print " {:<50}: {:>14}".format("Age at which Lynn takes social security",
- self.social_security_age[constants.LYNN])
- print " {:<50}: {:>14}".format("Lynn's first year social security benefit",
- self.initial_social_security_dollars[constants.LYNN])
- print " {:<50}: {:>14}".format("Age at which Scott takes social security",
- self.social_security_age[constants.SCOTT])
- print " {:<50}: {:>14}".format("Scott's first year social security benefit",
- self.initial_social_security_dollars[constants.SCOTT])
- print " Federal tax brackets ["
- self.federal_ordinary_income_tax_brackets.dump()
- print " ]"
-
- print " Federal dividend and long term gain brackets ["
- self.federal_dividends_and_long_term_gains_brackets.dump()
- print " ]"
- print " We assume Roth money continues to be available tax-free."
-
-class mutable_dynamic_historical_parameters(mutable_default_parameters):
- def __init__(self):
- self.selection_index = None
- self.selection_duration = 0
- self.historical_tuples = [
- # year stock infl bond
- (2020, 3.14, 1.90, 1.54),
- (2019, 31.49, 1.70, 2.15),
- (2018, -4.38, 2.40, 2.33),
- (2017, 21.83, 2.10, 1.19),
- (2016, 11.96, 1.30, 0.61),
- (2015, 1.38, 0.10, 0.32),
- (2014, 13.69, 1.60, 0.12),
- (2013, 32.39, 1.50, 0.13),
- (2012, 16.00, 2.10, 0.17),
- (2011, 2.11, 3.20, 0.18),
- (2010, 15.06, 1.60, 0.32),
- (2009, 26.46, -0.40, 0.47),
- (2008,-37.00, 3.80, 1.83),
- (2007, 5.49, 2.80, 4.53),
- (2006, 15.79, 3.20, 4.94),
- (2005, 4.91, 3.40, 3.62),
- (2004, 10.88, 2.70, 1.89),
- (2003, 28.68, 2.30, 1.24),
- (2002,-22.10, 1.60, 2.00),
- (2001,-11.89, 2.80, 3.49),
- (2000, -9.10, 3.40, 6.11),
- (1999, 21.04, 2.20, 5.08),
- (1998, 28.58, 1.50, 5.05),
- (1997, 33.36, 2.30, 5.63),
- (1996, 22.96, 3.00, 5.52),
- (1995, 37.58, 2.80, 5.94),
- (1994, 1.32, 2.60, 5.32),
- (1993, 10.08, 3.00, 3.43),
- (1992, 7.62, 3.00, 3.89),
- (1991, 30.47, 4.20, 5.86),
- (1990, -3.10, 5.40, 7.89),
- (1989, 31.69, 4.82, 8.54),
- (1988, 16.61, 4.14, 7.65),
- (1987, 15.25, 3.65, 6.77),
- (1986, 18.67, 0.86, 6.45),
- (1985, 31.73, 3.56, 8.42),
- (1984, 6.27, 4.32, 10.91),
- (1983, 22.56, 3.21, 9.58),
- (1982, 21.55, 6.16, 12.27),
- (1981, -4.91, 10.32, 14.80),
- (1980, 32.42, 13.50, 12.00),
- (1979, 18.44, 11.35, 10.65),
- (1978, 6.65, 7.59, 7.00),
- (1977, -7.18, 6.50, 6.08),
- (1976, 23.84, 5.76, 5.88),
- (1975, 37.20, 9.13, 6.78),
- (1974,-26.47, 11.04, 8.20),
- (1973,-14.66, 6.22, 7.32),
- (1972, 18.90, 3.21, 4.95),
- (1971, 14.31, 4.38, 4.89),
- (1970, 14.01, 5.72, 6.90),
- (1969, -8.50, 5.46, 7.12),
- (1968, 11.06, 4.19, 5.69),
- (1967, 23.98, 3.09, 4.88),
- (1966,-10.06, 2.86, 5.20),
- (1965, 12.45, 1.61, 4.15),
- (1964, 16.48, 1.31, 3.85),
- (1963, 22.80, 1.32, 3.36),
- (1962, -8.73, 1.00, 2.90),
- (1961, 26.89, 1.01, 2.60),
- (1960, 0.47, 1.72, 3.24),
- (1959, 11.96, 0.69, 3.83),
- (1958, 43.36, 2.85, 3.5),
- (1957,-10.78, 3.31, 3.6),
- (1956, 6.56, 1.49, 2.9),
- (1955, 31.56, -0.37, 2.5),
- (1954, 52.62, 0.75, 2.37),
- (1953, -0.99, 0.75, 2.71),
- (1952, 18.37, 1.92, 2.19),
- (1951, 24.02, 7.88, 2.00),
- (1950, 31.71, 1.26, 1.98),
- (1949, 18.79, -1.24, 2.21),
- (1948, 5.50, 8.07, 2.40),
- (1947, 5.71, 14.36, 2.01),
- (1946, -8.07, 8.33, 1.64),
- (1945, 36.44, 2.27, 1.67),
- (1944, 19.75, 1.73, 1.86),
- (1943, 25.90, 6.13, 2.05),
- (1942, 20.34, 10.88, 2.36),
- (1941,-11.59, 5.00, 2.10),
- (1940, -9.78, 0.72, 2.76),
- (1939, -0.41, -1.42, 2.76),
- (1938, 31.12, -2.08, 2.94),
- (1937,-35.03, 3.60, 2.76),
- (1936, 33.92, 1.46, 3.39),
- (1935, 47.67, 2.24, 2.76),
- (1934, -1.44, 3.08, 2.76),
- (1933, 53.99, -5.11, 4.71),
- (1932, -8.19, -9.87, 4.71),
- (1931,-43.34, -8.90, 3.99),
- (1930,-24.90, -2.34, 4.71),
- (1929, -8.42, 0.00, 4.27) ]
- mutable_default_parameters.__init__(self)
-
- def report_year(self, year):
- if self.selection_index is None:
- self.selection_index = random.randint(0,
- len(self.historical_tuples) - 1)
- self.selection_duration = 1
-
- t = self.historical_tuples[self.selection_index]
- sim_year = t[0]
- stock_return = t[1]
- inflation = t[2]
- bond_return = t[3]
- print
- print "## REPLAY YEAR %d" % sim_year
- inflation_multiplier = utils.convert_rate_to_multiplier(inflation)
- self.set_average_inflation_multiplier(inflation_multiplier)
- print "## INFLATION is %s (%f)" % (
- utils.format_rate(inflation_multiplier),
- inflation_multiplier)
- ss_multiplier = inflation_multiplier
- if ss_multiplier >= 1.0:
- ss_multiplier -= 1.0
- ss_multiplier *= 0.6666
- ss_multiplier += 1.0
- self.set_average_social_security_multiplier(ss_multiplier)
- print "## SS is %s (%f)" % (
- utils.format_rate(self.get_average_social_security_multiplier()),
- ss_multiplier)
-
- # Assumes 50/50 Stocks/Bonds portfolio
- our_return = (stock_return * 0.5 +
- bond_return * 0.5)
- self.set_average_investment_return_multiplier(utils.convert_rate_to_multiplier(our_return))
- print "## 50/50 INVESTMENTS RETURN is %s" % utils.format_rate(self.get_average_investment_return_multiplier())
- self.selection_duration += 1
- self.selection_index -= 1
- if self.selection_index < 0 or random.randint(1, 100) < 20:
- self.selection_index = None
- self.selection_duration = None
-
--- /dev/null
+#!/usr/bin/env python3
+
+import enum
+
+
+class Person(enum.Enum):
+ UNKNOWN = 0
+ SCOTT = 1
+ LYNN = 2
+ SHARED = 3
--- /dev/null
+#!/usr/bin/env python3
+
+import statistics as stats
+from typing import Iterable
+
+import histogram
+from type.money import Money
+from type.rate import Rate
+
+import simulation
+import simulation_results
+
+
+def summarize_results(
+ results: Iterable[simulation_results.SimulationResults]
+) -> None:
+ num_successes = 0
+ num_failures = 0
+ num_total = 0
+ avg_failure_year = 0
+ buckets = histogram.SimpleHistogram.n_evenly_spaced_buckets(10000, 1000*1000*100, 6)
+ buckets.insert(0, (0, 9999))
+ hist = histogram.SimpleHistogram(buckets)
+
+ failure_years = []
+ for result in results:
+ hist.add_item(float(result.final_networth.amount))
+ if result.success:
+ num_successes += 1
+ else:
+ failure_years.append(result.end_year)
+ avg_failure_year += result.end_year
+ num_failures += 1
+ num_total += 1
+ if num_failures > 0:
+ avg_failure_year /= num_failures
+
+ nw = [result.final_networth for result in results]
+
+ nw.sort()
+ with simulation.ReportColorizer():
+ print('SIMULATION RESULTS:')
+ print(f' Number of trials: {len(nw)}')
+ print(f' Successes and Failures: {num_successes} successes, {num_failures} failures ({Rate(num_successes/num_total)})')
+ if num_failures > 0:
+ print(f' Average failure year: {avg_failure_year:4.0f}')
+ print(f' Stdev failure year: {stats.pstdev(failure_years):.1f} years')
+ print(f' Min ending networth: {nw[0]}')
+ print(f' p25 ending networth: {nw[len(nw)//4]}')
+ print(f' Mean ending networth: {Money(stats.mean([x.final_networth.amount for x in results]))}')
+ print(f' p50 ending networth: {nw[len(nw)//2]}')
+ print(f' p75 ending networth: {nw[len(nw)//4*3]}')
+ print(f' Max ending networth: {nw[-1]}')
+ print
+ print(f' Stdev ending networth: {Money(stats.pstdev([x.final_networth.amount for x in results]))}')
+ print('')
+ print(hist.__repr__(label_formatter='$%-9s'))
-#!/usr/local/bin/python
-
-import sys
-import traceback
-
-from accounts import *
-import constants
-from parameters import *
-from tax_brackets import tax_brackets
-from tax_collector import tax_collector
-import utils
-import secrets
-from money import money
-
-class simulation(object):
- def __init__(self, parameters, accounts):
- """Initialize simulation parameters and starting account balances"""
- self.params = parameters
- self.accounts = accounts
- self.lynn_age = 0
- self.scott_age = 0
- self.alex_age = 0
- self.year = 0
- self.max_net_worth = 0
-
- def do_rmd_withdrawals(self, taxes):
- """Determine if any account that has RMDs will require someone to
- take money out of it this year and, if so, how much. Then do
- the withdrawal."""
- total_withdrawn = money(0)
- for x in self.accounts:
- if x.has_rmd() and x.get_owner() == constants.SCOTT:
- rmd = x.do_rmd_withdrawal(self.scott_age, taxes)
- total_withdrawn += rmd
- elif x.has_rmd() and x.get_owner() == constants.LYNN:
- rmd = x.do_rmd_withdrawal(self.lynn_age, taxes)
- total_withdrawn += rmd
- return total_withdrawn
-
- def go_find_money(self, amount_needed, taxes):
- """Look through accounts and try to find amount using some heuristics
- about where to withdraw money first."""
-
- # Try brokerage accounts first
- for x in self.accounts:
- if not x.is_age_restricted():
- amount_to_withdraw = min(amount_needed, x.get_balance())
- if amount_to_withdraw > 0:
- print "## Withdrawing %s from %s" % (amount_to_withdraw,
- x.get_name())
- x.withdraw(amount_to_withdraw, taxes)
- amount_needed -= amount_to_withdraw
- if amount_needed <= 0: return
-
- # Next try age restircted accounts
- for x in self.accounts:
- if (x.is_age_restricted() and
- x.has_rmd() and
- ((x.belongs_to_lynn() and self.lynn_age > 60) or
- (x.belongs_to_scott() and self.scott_age > 60))):
-
- amount_to_withdraw = min(amount_needed, x.get_balance())
- if amount_to_withdraw > 0:
- print "## Withdrawing %s from %s" % (amount_to_withdraw,
- x.get_name())
- x.withdraw(amount_to_withdraw, taxes)
- amount_needed -= amount_to_withdraw
- if amount_needed <= 0: return
-
- # Last try Roth accounts
- for x in self.accounts:
- if (x.is_age_restricted() and
- x.has_roth() and
- ((x.belongs_to_lynn() and self.lynn_age > 60) or
- (x.belongs_to_scott() and self.scott_age > 60))):
-
- amount_to_withdraw = min(amount_needed, x.get_balance())
- if amount_to_withdraw > 0:
- print "## Withdrawing %s from %s" % (amount_to_withdraw,
- x.get_name())
- x.withdraw(amount_to_withdraw, taxes)
- amount_needed -= amount_to_withdraw
- if amount_needed <= 0: return
- raise Exception("Unable to find enough money this year, still need %s more!" % amount_needed)
-
- def get_social_security(self,
- scott_annual_social_security_dollars,
- lynn_annual_social_security_dollars,
- taxes):
- """Figure out if Scott and/or Lynn are taking social security at
- their present age in the simulation and, if so, how much their
- annual benefit should be."""
- total_benefit = money(0)
- if self.scott_age >= self.params.get_initial_social_security_age(constants.SCOTT):
- total_benefit += scott_annual_social_security_dollars
- taxes.record_ordinary_income(scott_annual_social_security_dollars)
- if self.lynn_age >= self.params.get_initial_social_security_age(constants.LYNN):
- total_benefit += lynn_annual_social_security_dollars
- taxes.record_ordinary_income(lynn_annual_social_security_dollars)
- return total_benefit
-
- def do_opportunistic_roth_conversions(self, taxes):
- """Roll over money from pretax 401(k)s or IRAs into Roth."""
- desired_conversion_amount = (
- taxes.how_many_more_dollars_can_we_earn_without_changing_tax_rate(
- self.params.get_federal_ordinary_income_tax_brackets()))
- if desired_conversion_amount > 0:
- money_converted = 0
- for x in self.accounts:
- if x.has_roth():
- amount = x.do_roth_conversion(desired_conversion_amount, taxes)
- money_converted += amount
- desired_conversion_amount -= amount
- if money_converted > 0:
- return True
- return False
-
- def dump_report_header(self):
- self.params.dump()
-
- def dump_annual_header(self, money_needed):
- print "\nYear: %d, estimated annual expenses %s ---------------\n" % (
- self.year,
- money_needed)
- print "Scott is %d, Lynn is %d and Alex is %d.\n" % (
- self.scott_age, self.lynn_age, self.alex_age)
-
- # Print out how much money is in each account + overall net worth.
- total = money(0)
- for x in self.accounts:
- total += x.get_balance()
- print "{:<50}: {:>14}".format(x.get_name(), x.get_balance())
- print "{:<50}: {:>14}\n".format("TOTAL", total)
- if self.max_net_worth < total:
- self.max_net_worth = total
-
- def dump_final_report(self, taxes):
- print "\nAGGREGATE STATS FINAL REPORT:"
- total = money(0)
- for x in self.accounts:
- x.dump_final_report()
- total += x.get_balance()
- taxes.dump_final_report()
- print " {:<50}: {:>14}".format("Max net worth achieved",
- self.max_net_worth)
- print "==> {:<50}: {:>14}".format("Final net worth of simulation",
- total)
-
- def run(self):
- """Run the simulation!"""
- self.dump_report_header()
-
- taxes = tax_collector()
- adjusted_annual_expenses = self.params.get_initial_annual_expenses()
- adjusted_scott_annual_social_security_dollars = (
- self.params.get_initial_social_security_benefit(constants.SCOTT))
- adjusted_lynn_annual_social_security_dollars = (
- self.params.get_initial_social_security_benefit(constants.LYNN))
-
-
- try:
- for self.year in xrange(2020, 2080):
- self.scott_age = self.year - 1974
- self.lynn_age = self.year - 1964
- self.alex_age = self.year - 2005
- self.params.report_year(self.year)
-
- # Computed money needed this year based on inflated budget.
- money_needed = money(adjusted_annual_expenses)
-
- # When Alex is in college, we need $50K more per year.
- if self.alex_age > 18 and self.alex_age <= 22:
- money_needed += 50000
-
- self.dump_annual_header(money_needed)
-
- # Now, figure out how to find money to pay for this year
- # and how much of it is taxable.
- total_income = money(0)
-
- # When we reach a certain age we have to take RMDs from
- # some accounts. Handle that here.
- rmds = self.do_rmd_withdrawals(taxes)
- if rmds > 0:
- print "## Satisfied %s of RMDs from age-restricted accounts." % rmds
- total_income += rmds
- money_needed -= rmds
-
- # When we reach a certain age we are eligible for SS
- # payments.
- ss = self.get_social_security(
- adjusted_scott_annual_social_security_dollars,
- adjusted_lynn_annual_social_security_dollars,
- taxes)
- if ss > 0:
- print "## Social security paid %s" % ss
- total_income += ss
- money_needed -= ss
-
- # If we still need money, try to go find it.
- if money_needed > 0:
- self.go_find_money(money_needed, taxes)
- total_income += money_needed
- money_needed = 0
-
- look_for_conversions = True
- tax_limit = money(25000)
- while True:
- # Maybe do some opportunistic Roth conversions.
- taxes_due = taxes.approximate_taxes(
- self.params.get_federal_standard_deduction(),
- self.params.get_federal_ordinary_income_tax_brackets(),
- self.params.get_federal_dividends_and_long_term_gains_income_tax_brackets())
- total_income = taxes.get_total_income()
- tax_rate = 0.0
- if total_income > 0:
- tax_rate = float(taxes_due) / float(total_income)
-
- if (look_for_conversions and
- tax_rate <= 0.14 and
- taxes_due < tax_limit and
- self.year <= 2036):
-
- look_for_conversions = self.do_opportunistic_roth_conversions(taxes)
- # because these conversions affect taxes, spin
- # once more in the loop and recompute taxes
- # due and tax rate.
- else:
- break
-
- # Pay taxes_due by going to find more money. This is a
- # bit hacky since withdrawing more money to cover taxes
- # will, in turn, cause taxable income. But I think taxes
- # are low enough and this simulation is rough enough that
- # we can ignore this.
- if taxes_due > 0:
- print "## Estimated federal tax due: %s (this year's tax rate=%s)" % (
- taxes_due, utils.format_rate(tax_rate))
- self.go_find_money(taxes_due, taxes)
- else:
- print "## No federal taxes due this year!"
- taxes.record_taxes_paid_and_reset_for_next_year(taxes_due)
-
- # Inflation and appreciation:
- # * Cost of living increases
- # * Social security benefits increase
- # * Tax brackets are adjusted for inflation
- inflation_multiplier = self.params.get_average_inflation_multiplier()
- returns_multiplier = self.params.get_average_investment_return_multiplier()
- ss_multiplier = self.params.get_average_social_security_multiplier()
- adjusted_annual_expenses *= inflation_multiplier
- for x in self.accounts:
- x.appreciate(returns_multiplier)
- if self.scott_age >= self.params.get_initial_social_security_age(constants.SCOTT):
- adjusted_scott_annual_social_security_dollars *= ss_multiplier
- if self.lynn_age >= self.params.get_initial_social_security_age(constants.LYNN):
- adjusted_lynn_annual_social_security_dollars *= ss_multiplier
- self.params.get_federal_ordinary_income_tax_brackets().adjust_with_multiplier(inflation_multiplier)
- self.params.get_federal_dividends_and_long_term_gains_income_tax_brackets().adjust_with_multiplier(inflation_multiplier)
- succeeded = True
- except Exception as e:
- print "Exception: %s" % e
- print traceback.print_exc(e)
- print "Ran out of money!?!"
- succeeded = False
-
- finally:
- self.dump_final_report(taxes)
- return succeeded
-
-# main
-#params = mutable_default_parameters()
-
-params = None
-accounts = None
-s = None
-for x in xrange(1, 100):
- del params
- del accounts
- del s
- params = mutable_dynamic_historical_parameters()
- accounts = secrets.get_starting_account_list()
- s = simulation(params, accounts)
- print "====================== Simulation %d ======================" % x
- if s.run() == False:
- print "This simulation failed!"
- sys.exit(0)
+#!/usr/bin/env python3
+
+from typing import List
+
+import bootstrap
+import config
+
+import account
+from person import Person
+import result_summarizer
+import returns_and_expenses as rai
+import simulation
+import simulation_params
+import trials
+from type.money import Money
+
+
+args = config.add_commandline_args(
+ 'Retire!',
+ 'Args that drive the retirement simulator',
+)
+args.add_argument(
+ '--num_trials',
+ '-n',
+ type=int,
+ default=1,
+ help='How many simulations to run'
+)
+args.add_argument(
+ '--verbosity',
+ '-v',
+ type=int,
+ choices=range(0, 3),
+ default=1,
+ help='How verbose should I be?',
+)
+
+
+# This defines the set of account and their initial balances.
+accounts: List[account.Account] = [
+
+ # Your accounts here....
+
+]
+
+
+def main() -> None:
+ params = simulation_params.DEFAULT_SIMULATION_PARAMS
+ params.initial_account_states = accounts
+ params.returns_and_expenses = rai.GaussianRae() #rai.HistoricalRaE()
+
+ if config.config['num_trials'] > 1:
+ with simulation.ReportColorizer():
+ print(params)
+ results = trials.run_multiple_trials(
+ params=params,
+ num_trials=config.config['num_trials']
+ )
+ print
+ result_summarizer.summarize_results(results)
+ else:
+ sim = simulation.Simulation(params)
+ results = sim.simulate(
+ simulation.Verbosity(config.config['verbosity'])
+ )
+ if not results.success:
+ print("Unsuccessful")
+ return 1
+ return 0
+
+
+if __name__ == '__main__':
+ main()
--- /dev/null
+#!/usr/bin/env python3
+
+from abc import ABC, abstractmethod
+import logging
+import random
+
+from type.rate import Rate
+
+import data
+
+
+logger = logging.getLogger(__name__)
+
+
+class ReturnsAndExpenses(ABC):
+ @abstractmethod
+ def get_annual_inflation_rate(self, year: int) -> Rate:
+ pass
+
+ @abstractmethod
+ def get_annual_social_security_increase_rate(self, year: int) -> Rate:
+ pass
+
+ @abstractmethod
+ def get_annual_investment_return_rate(self, year: int) -> Rate:
+ pass
+
+
+class ConstantValueRaE(ReturnsAndExpenses):
+ def __init__(
+ self,
+ *,
+ inflation_rate = Rate(0.0),
+ social_security_increase_rate = Rate(0.0),
+ investment_return_rate = Rate(0.0),
+ ):
+ super().__init__()
+ self.inflation_rate = inflation_rate
+ self.social_security_increase_rate = social_security_increase_rate
+ self.investment_return_rate = investment_return_rate
+
+ def get_annual_inflation_rate(self, year: int) -> Rate:
+ return self.inflation_rate
+
+ def get_annual_social_security_increase_rate(self, year: int) -> Rate:
+ return self.social_security_increase_rate
+
+ def get_annual_investment_return_rate(self, year: int) -> Rate:
+ return self.investment_return_rate
+
+ def __repr__(self):
+ return f'Constant rates: inflation={self.inflation_rate}, ss={self.social_security_increase_rate}, roi={self.investment_return_rate}'
+
+
+class HistoricalRaE(ReturnsAndExpenses):
+ def __init__(self):
+ super().__init__()
+ #self.years = range(1928, 2020)
+ self.years = [1929, 1930, 1931, 1932, 1933,
+ 1939,
+ 1946, 1947,
+ 1956, 1957,
+ 1964, 1965, 1966, 1969,
+ 1973, 1974, 1977, 1978,
+ 1981, 1984, 1987, 1990,
+ 2000, 2001, 2002, 2007, 2007]
+ self.year = None
+
+ def get_annual_inflation_rate(self, year: int) -> Rate:
+ #self.year = random.randrange(1928, 2020)
+ self.year = random.choice(self.years)
+ rois_and_inflation = data.ROIs_AND_INFLATION[self.year]
+ inflation = rois_and_inflation.inflation
+ logger.debug(f'HistoricalRaE: inflation is {inflation} (replay={self.year})')
+ return inflation
+
+ def get_annual_social_security_increase_rate(self, year: int) -> Rate:
+ rois_and_inflation = data.ROIs_AND_INFLATION[self.year]
+ ss = rois_and_inflation.inflation
+ if ss > 0:
+ ss -= 1.0
+ ss /= 2
+ ss += 1.0
+ logger.debug(f'HistoricalRaE: social security increase rate is {ss} (replay={self.year})')
+ return ss
+
+ def get_annual_investment_return_rate(self, year: int) -> Rate:
+ rois_and_inflation = data.ROIs_AND_INFLATION[self.year]
+ roi = (
+ rois_and_inflation.snp_500_return * 0.50 +
+ rois_and_inflation.us_10y_bond_return * 0.50
+ )
+ logger.debug(f'HistoricalRaE: investment return 50/50 is {roi} (replay={self.year})')
+ return Rate(roi)
+
+ def __repr__(self):
+ return f'historical reply of inflation/returns in {self.years}'
+
+
+class GaussianRae(ReturnsAndExpenses):
+ def __init__(self):
+ self.inflation = None
+ self.mean_inflation = 1.04 # Historical = 3.5% inflation
+ self.stdev_inflation = 0.028 # Historical = 2.8% inflation stdev
+ self.mean_roi = 1.060 # Historical = 8.5% 30s/70b; 9.1% 50s/50s; 9.7% 70s/30b
+ self.stdev_roi = 0.093 # Historical = 6.7% 30s/70b; 9.3% 50s/50b; 12.2% 70s/30b
+ self.ss_discount = 0.02
+
+ def get_annual_inflation_rate(self, year: int) -> Rate:
+ self.inflation = random.gauss(self.mean_inflation, self.stdev_inflation)
+ return Rate(multiplier=self.inflation)
+
+ def get_annual_social_security_increase_rate(self, year: int) -> Rate:
+ return Rate(multiplier=self.inflation - self.ss_discount)
+
+ def get_annual_investment_return_rate(self, year: int) -> Rate:
+ return Rate(multiplier=random.gauss(self.mean_roi, self.stdev_roi))
+
+ def __repr__(self):
+ return f'Random Gaussian: (μinflation={Rate(self.mean_inflation).__repr__(relative=True)}, σ={Rate(self.stdev_inflation)}); (μroi={Rate(self.mean_roi).__repr__(relative=True)}, σ={Rate(self.stdev_roi)})'
+++ /dev/null
-import accounts
-
-def get_starting_account_list:
- return [ <<< your accounts >>> ]
--- /dev/null
+#!/usr/bin/env python3
+
+import enum
+import logging
+import re
+
+import ansi
+from type.money import Money
+
+from person import Person
+import simulation_params as sp
+import simulation_results as sr
+import taxman
+
+
+logger = logging.getLogger(__name__)
+
+
+class Verbosity(enum.IntEnum):
+ SILENT = 0
+ NORMAL = 1
+ VERBOSE = 2
+
+
+class ReportColorizer(ansi.ProgrammableColorizer):
+ def __init__(self):
+ super().__init__(
+ [
+ (re.compile('^(.+)\:$', re.MULTILINE), self.header),
+ (re.compile(' *([A-Z]+[A-Z ]+)\: *'), self.title)
+ ]
+ )
+
+ def header(self, match: re.match):
+ return f'{ansi.fg("chateau green")}{match.group()}{ansi.reset()}'
+
+ def title(self, match: re.match):
+ s = match.group()
+ if len(s) > 14:
+ return f'{ansi.fg("mint green")}{ansi.bold()}{ansi.underline()}{s}{ansi.reset()}'
+ return s
+
+
+class Simulation(object):
+ def __init__(
+ self,
+ params: sp.SimulationParameters,
+ ):
+ self.year = 0
+ self.scott_age = 0
+ self.lynn_age = 0
+ self.alex_age = 0
+ self.accounts = [x for x in params.initial_account_states]
+ self.taxes = taxman.TaxCollector(
+ params.federal_ordinary_income_tax_brackets,
+ params.federal_dividends_and_long_term_gains_brackets
+ )
+ self.params = params
+ self.returns_and_expenses = params.returns_and_expenses
+
+ def do_rmd_withdrawals(self) -> Money:
+ """Determine if any account that has RMDs will require someone to
+ take money out of it this year and, if so, how much. Then do
+ the withdrawal."""
+ total_withdrawn = 0
+ for x in self.accounts:
+ if x.has_rmd() and x.get_owner() == Person.SCOTT:
+ rmd = x.do_rmd_withdrawal(self.scott_age, self.taxes)
+ total_withdrawn += rmd
+ elif x.has_rmd() and (x.get_owner() == Person.LYNN or x.get_owner() == Person.SHARED):
+ rmd = x.do_rmd_withdrawal(self.lynn_age, self.taxes)
+ total_withdrawn += rmd
+ return total_withdrawn
+
+ def go_find_money(self, amount: Money):
+ """Look through accounts and try to find amount using some heuristics
+ about where to withdraw money first."""
+ logger.debug(f'Simulation: looking for {amount}...')
+
+ # Try brokerage accounts first
+ for x in self.accounts:
+ if not x.is_age_restricted():
+ if x.get_balance() >= amount:
+ logger.debug(
+ f'Simulation: withdrawing {amount} from {x.get_name()}'
+ )
+ x.withdraw(amount, self.taxes)
+ return
+
+ elif x.get_balance() > 0.01 and x.get_balance() < amount:
+ money_available = x.get_balance()
+ logger.debug(
+ f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
+ '(and exhausting the account)'
+ )
+ x.withdraw(money_available, self.taxes)
+ amount -= money_available
+
+ # Next try age restircted accounts
+ for x in self.accounts:
+ if (
+ x.is_age_restricted() and
+ x.has_rmd() and
+ x.belongs_to_lynn() and
+ self.lynn_age > 60
+ ):
+ if x.get_balance() >= amount:
+ logging.debug(
+ f'Simulation: withdrawing {amount} from {x.get_name()}'
+ )
+ x.withdraw(amount, self.taxes)
+ return
+
+ elif x.get_balance() > 0.01 and x.get_balance() < amount:
+ money_available = x.get_balance()
+ logger.debug(
+ f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
+ '(and exhausting the account)'
+ )
+ x.withdraw(money_available, self.taxes)
+ amount -= money_available
+
+ if (
+ x.is_age_restricted() and
+ x.has_rmd() and
+ x.belongs_to_scott() and
+ self.scott_age > 60
+ ):
+ if x.get_balance() >= amount:
+ logger.debug(
+ f'Simulation: withdrawing {amount} from {x.get_name()}'
+ )
+ x.withdraw(amount, self.taxes)
+ return
+
+ elif x.get_balance() > 0.01 and x.get_balance() < amount:
+ money_available = x.get_balance()
+ logger.debug(
+ f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
+ '(and exhausting the account)'
+ )
+ x.withdraw(money_available, self.taxes)
+ amount -= money_available
+
+ # Last try Roth accounts
+ for x in self.accounts:
+ if (
+ x.is_age_restricted() and
+ x.has_roth() and
+ x.belongs_to_lynn() and
+ self.lynn_age > 60
+ ):
+ if x.get_balance() >= amount:
+ logger.debug(
+ f'Simulation: withdrawing {amount} from {x.get_name()}'
+ )
+ x.withdraw(amount, self.taxes)
+ return
+
+ elif x.get_balance() > 0.01 and x.get_balance() < amount:
+ money_available = x.get_balance()
+ logger.debug(
+ f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
+ '(and exhausting the account)'
+ )
+ x.withdraw(money_available, self.taxes)
+ amount -= money_available
+
+ if (
+ x.is_age_restricted() and
+ x.has_roth() and
+ x.belongs_to_scott() and
+ self.scott_age > 60
+ ):
+ if x.get_balance() >= amount:
+ logger.debug(
+ f'Simulation: withdrawing {amount} from {x.get_name()}'
+ )
+ x.withdraw(amount, self.taxes)
+ return
+
+ elif x.get_balance() > 0.01 and x.get_balance() < amount:
+ money_available = x.get_balance()
+ logger.debug(
+ f'Simulation: withdrawing {money_available} from {x.get_name()} ' +
+ '(and exhausting the account)'
+ )
+ x.withdraw(money_available, self.taxes)
+ amount -= money_available
+ raise Exception("Unable to find enough money this year!")
+
+ def get_social_security(self) -> Money:
+ total_benefit = Money(0)
+ if self.scott_age >= self.params.scott_social_security_age:
+ total_benefit += self.params.scott_annual_social_security_dollars
+ self.taxes.record_ordinary_income(
+ self.params.scott_annual_social_security_dollars
+ )
+ if self.lynn_age >= self.params.lynn_social_security_age:
+ total_benefit += self.params.lynn_annual_social_security_dollars
+ self.taxes.record_ordinary_income(
+ self.params.lynn_annual_social_security_dollars
+ )
+ return total_benefit
+
+ def do_opportunistic_roth_conversions(self) -> Money:
+ desired_conversion_amount = (
+ self.taxes.how_many_more_dollars_can_we_earn_without_changing_tax_rate()
+ )
+ money_converted = Money(0)
+ for x in self.accounts:
+ if x.has_roth():
+ amount = x.do_roth_conversion(desired_conversion_amount)
+ money_converted += amount
+ desired_conversion_amount -= amount
+ return money_converted
+
+ def simulate(
+ self,
+ silent: Verbosity = Verbosity.NORMAL
+ ) -> sr.SimulationResults:
+ if silent is not Verbosity.SILENT:
+ with ReportColorizer():
+ print(self.params)
+
+ results = sr.SimulationResults.create()
+ try:
+ for year in range(2022, 2069):
+ self.year = year
+ self.scott_age = year - 1974
+ self.lynn_age = year - 1964
+ self.alex_age = year - 2005
+
+ # Computed money needed this year based on inflated budget.
+ money_needed = self.params.annual_expenses
+
+ # When Alex is in college, we need $60K more per year.
+ if self.alex_age > 18 and self.alex_age <= 22:
+ money_needed += Money(60000)
+
+ if silent is Verbosity.VERBOSE:
+ print(
+ f'\nYear: {year}, estimated annual expenses {money_needed} -----------------\n'
+ )
+ print(
+ f'Scott is {self.scott_age}, Lynn is {self.lynn_age} and Alex is {self.alex_age}'
+ )
+
+ # Print out how much money is in each account +
+ # overall net worth.
+ total = 0
+ if silent is not Verbosity.VERBOSE:
+ for x in self.accounts:
+ total += x.get_balance()
+ else:
+ for x in self.accounts:
+ total += x.get_balance()
+ print(' %-50s: %18s' % (x.get_name(), x.get_balance()))
+ print(" %-50s: %18s" % ("TOTAL NET WORTH", total))
+
+ # Max
+ if total > results.max_networth:
+ results.max_networth = total
+ results.max_networth_year = year
+
+ # Now, figure out how to find money to pay for this year
+ # and how much of it is taxable.
+ total_income = 0
+
+ # When we reach a certain age we have to take RMDs from
+ # some accounts. Handle that here.
+ rmds = self.do_rmd_withdrawals()
+ if rmds > 0:
+ logger.debug(
+ f'Simulation: satisfied {rmds} of RMDs from age-restricted accounts.'
+ )
+ total_income += rmds
+ money_needed -= rmds
+ if rmds > results.max_rmd:
+ results.max_rmd = rmds
+ results.max_rmd_year = year
+
+ # When we reach a certain age we are eligible for SS
+ # payments.
+ ss = self.get_social_security()
+ results.total_social_security += ss
+ if ss > 0:
+ logger.debug(
+ f'Simulation: received {ss} from social security.'
+ )
+ total_income += ss
+ money_needed -= ss
+
+ # If we still need money, try to go find it in accounts.
+ if money_needed > 0:
+ self.go_find_money(money_needed)
+ total_income += money_needed
+ money_needed = 0
+
+ # Maybe do some opportunistic Roth conversions.
+ taxes_due = self.taxes.approximate_annual_taxes()
+ old_taxes_due = taxes_due
+ tax_rate = self.taxes.approximate_annual_tax_rate()
+ if tax_rate <= 0.16 and year <= 2035:
+ rollover = self.do_opportunistic_roth_conversions()
+ if rollover > results.max_rollover:
+ results.max_rollover = rollover
+ results.max_rollover_year = year
+ results.total_rollover += rollover
+
+ # These conversions will affect taxes due.
+ # Recompute them now.
+ if rollover > 0:
+ taxes_due = self.taxes.approximate_annual_taxes()
+ tax_rate = self.taxes.approximate_annual_tax_rate()
+ logger.debug(
+ f'Simulation: rolled {rollover} from pretax into Roth which increased '
+ f'taxes by {taxes_due - old_taxes_due}'
+ )
+
+ # Pay taxes_due by going to find more money. This is
+ # a bit hacky since withdrawing more money to cover
+ # taxes will, in turn, cause taxable income. But I
+ # think taxes are low enough and this simulation is
+ # rough enough that we can ignore this.
+ if taxes_due > results.max_taxes:
+ results.max_taxes = taxes_due
+ results.max_taxes_year = year
+ if taxes_due > 0:
+ logger.debug(
+ f'Simulation: estimated {taxes_due} federal tax liability (tax rate={tax_rate})'
+ )
+ self.go_find_money(taxes_due)
+ else:
+ logger.debug('Simulation: estimating no tax burden this year')
+ self.taxes.record_taxes_paid_and_reset_for_next_year(year)
+
+ # Inflation and appreciation. Note also: SS benefits
+ # currently are increased annually to adjust for
+ # inflation. Do that here too.
+ inflation = self.returns_and_expenses.get_annual_inflation_rate(year)
+ roi = self.returns_and_expenses.get_annual_investment_return_rate(year)
+ ss = self.returns_and_expenses.get_annual_social_security_increase_rate(year)
+ self.params.annual_expenses *= inflation
+ total = 0
+ for x in self.accounts:
+ total += x.appreciate(roi)
+ if self.scott_age >= self.params.scott_social_security_age:
+ self.params.scott_annual_social_security_dollars *= ss
+ if self.lynn_age >= self.params.lynn_social_security_age:
+ self.params.lynn_annual_social_security_dollars *= ss
+ msg = (
+ f'In {year}, '
+ f'inflation={inflation.__repr__(relative=True)}, ' +
+ f'ROI={roi.__repr__(relative=True)}, ' +
+ f'net worth={total}'
+ )
+ logger.debug(msg)
+ if silent is not Verbosity.SILENT:
+ print(msg)
+
+ except Exception as e:
+ if silent is not Verbosity.SILENT:
+ logger.exception(e)
+ print(f'**** Exception, out of money in {year}? ****')
+ results.success = False
+ else:
+ results.success = True
+
+ # Final report
+ results.end_year = self.year + 1
+ results.final_networth = Money(0)
+ if results.success:
+ for x in self.accounts:
+ results.final_networth += x.get_balance()
+ results.final_taxes = self.taxes
+ results.final_account_state = self.accounts
+ if silent is not Verbosity.SILENT:
+ with ReportColorizer():
+ print(results)
+ return results
--- /dev/null
+#!/usr/bin/env python3
+
+import account
+from dataclasses import dataclass
+import returns_and_expenses as rai
+from typing import List, Tuple
+
+import string_utils as su
+from type.money import Money
+
+import taxman
+
+
+@dataclass(repr=False)
+class SimulationParameters(object):
+ initial_account_states: List[account.Account]
+ initial_annual_expenses: Money
+ annual_expenses: Money
+ lynn_social_security_age: int
+ lynn_annual_social_security_dollars: float
+ scott_social_security_age: int
+ scott_annual_social_security_dollars: float
+ federal_dividends_and_long_term_gains_brackets: List[Tuple[Money, float]]
+ federal_ordinary_income_tax_brackets: List[Tuple[Money, float]]
+ returns_and_expenses: rai.ReturnsAndExpenses
+
+ def __repr__(self):
+ with su.SprintfStdout() as buf:
+ print('SIMULATION PARAMETERS:')
+ print(f' initial_annual_expenses: {self.initial_annual_expenses}')
+ print(f' current_annual_expenses: {self.annual_expenses}')
+ print(f' lynn_social_security_age: {self.lynn_social_security_age}')
+ print(f' lynn_annual_social_security_dollars: {self.lynn_annual_social_security_dollars}')
+ print(f' scott_social_security_age: {self.scott_social_security_age}')
+ print(f' scott_annual_social_security_dollars: {self.scott_annual_social_security_dollars}')
+ print(f' returns_and_expenses: {self.returns_and_expenses}')
+ print(' federal_ordinary_income_tax_brackets: [')
+ for t in self.federal_ordinary_income_tax_brackets:
+ print(f' {t[0]} => {t[1]}')
+ print(' ]')
+ print(' federal_dividends_and_long_term_gains_brackets: [')
+ for t in self.federal_dividends_and_long_term_gains_brackets:
+ print(f' {t[0]} => {t[1]}')
+ print(' ]')
+ return buf()
+
+
+DEFAULT_SIMULATION_PARAMS = SimulationParameters(
+ initial_account_states = [],
+ initial_annual_expenses = Money(150000),
+ annual_expenses = Money(150000),
+ lynn_social_security_age = 67,
+ lynn_annual_social_security_dollars = 40000,
+ scott_social_security_age = 67,
+ scott_annual_social_security_dollars = 40000,
+ federal_ordinary_income_tax_brackets = taxman.PESSIMISTIC_FEDERAL_INCOME_TAX_BRACKETS,
+ federal_dividends_and_long_term_gains_brackets = taxman.PESSIMISTIC_LONG_TERM_GAIN_FEDERAL_TAX_BRACKETS,
+ returns_and_expenses = None,
+)
--- /dev/null
+#!/usr/bin/env python3
+
+from dataclasses import dataclass
+from typing import List
+
+import string_utils as su
+from type.money import Money
+
+import account
+import taxman
+
+@dataclass(repr=False)
+class SimulationResults(object):
+ success: bool
+ end_year: int
+ max_networth: Money
+ max_networth_year: int
+ max_taxes: Money
+ max_taxes_year: int
+ max_rollover: Money
+ max_rollover_year: int
+ max_rmd: Money
+ max_rmd_year: int
+ total_rollover: Money
+ total_social_security: Money
+ final_account_state: List[account.Account]
+ final_networth: Money
+ final_taxes: taxman.TaxCollector
+
+ @classmethod
+ def create(cls) -> "SimulationResults":
+ return SimulationResults(
+ False, 0,
+ Money(0), 0,
+ Money(0), 0,
+ Money(0), 0,
+ Money(0), 0,
+ Money(0), Money(0), [], Money(0), None
+ )
+
+ def __repr__(self):
+ total = Money(0)
+ for a in self.final_account_state:
+ total += a.get_balance()
+
+ with su.SprintfStdout() as ret:
+ print("SIMULATION RESULTS:")
+ print('%-54s: %18s' % ('Successful?', self.success))
+ for x in self.final_account_state:
+ x.dump_final_report()
+
+ print(self.final_taxes)
+ print('Simulation Maximums / Aggregates:')
+ print(' %-50s: %18s (%s)' % (
+ 'Maximum net worth', f'{self.max_networth}', f'in {self.max_networth_year}'
+ ))
+ print(' %-50s: %18s (%s)' % (
+ 'Final net worth', f'{total}', f'in {self.end_year}'
+ ))
+ print(' %-50s: %18s (%s)' % (
+ 'Maximum annual RMDs', f'{self.max_rmd}', f'in {self.max_rmd_year}'
+ ))
+ print(' %-50s: %18s' % (
+ 'Total social security', f'{self.total_social_security}'
+ ))
+ print(' %-50s: %18s (%s)' % (
+ 'Maximum annual taxes due', f'{self.max_taxes}', f'in {self.max_taxes_year}'
+ ))
+ print(' %-50s: %18s' % (
+ 'Total Roth conversions', f'{self.total_rollover}'
+ ))
+ print(' %-50s: %18s (%s)' % (
+ 'Maximum Roth conversion', f'{self.max_rollover}', f'in {self.max_rollover_year}'
+ ))
+ return ret()
+++ /dev/null
-import copy
-import utils
-from money import money
-
-class tax_brackets:
- """A class to represent tax brackets and some operations on them."""
-
- def __init__(self, brackets):
- self.brackets = copy.deepcopy(brackets)
-
- def compute_taxes_for_income(self, income):
- """Compute the tax bill for income given our brackets."""
- taxes_due = money(0)
- while income > 1:
- (threshold, rate) = self.get_bracket_for_income(income)
- taxes_due += (income - threshold) * rate
- income = threshold
- assert taxes_due >= 0, "Somehow computed negative tax bill?!"
- return taxes_due
-
- def get_bracket_for_income(self, income):
- """Return the bracket that the last dollar of income was in."""
- assert income > 0, "Income should be >0 to be taxed"
- rate = 0.0
- threshold = 0
- for bracket in self.brackets:
- # Bracket is a tuple of (monetary_threshold,
- # tax_rate). So bracket[0] is a dollar amount and
- # bracket[1] is a tax rate. e.g. ( 250000, 0.20 )
- # indicates that every dollar you earn over 250K is
- # taxed at 20%.
- if (income > bracket[0] and rate < bracket[1]):
- threshold = bracket[0]
- rate = bracket[1]
- return (threshold, rate)
-
- def get_bracket_above_income(self, income):
- """Return the next bracket above the one activated by income."""
- assert income > 0, "Income should be >0 to be taxed"
- rate = 1.0
- threshold = None
-
- # Walk the income tax brackets and look for the one just above
- # the one this year's income is activating.
- for bracket in self.brackets:
- if (bracket[0] > income and bracket[1] < rate):
- threshold = bracket[0]
- rate = bracket[1]
-
- # Note: this can return (None, 1.0) iff the income is already
- # in the top tax bracket.
- return (threshold, rate)
-
- def get_bracket_below_income(self, income):
- """Return the next bracket below the one activated by income."""
- assert income > 0, "Income should be >0 to be taxed"
- bracket = self.get_bracket_for_income(income)
- income = bracket[0]
- return self.get_bracket_for_income(income)
-
- def get_effective_tax_rate_for_income(self, income):
- """Compute and return the effective tax rate for an income."""
- if income <= 0: return 0
- tax_bill = self.compute_taxes_for_income(income)
- return float(tax_bill) / income
-
- def adjust_with_multiplier(self, multiplier):
- """Every year the IRS adjusts the income tax brackets for inflation."""
- for bracket in self.brackets:
- if bracket[0] != 1:
- bracket[0] *= multiplier
-
- def dump(self):
- """Print out the tax brackets we're using in here."""
- for x in self.brackets:
- print "{:<20} -> {:<3}".format(x[0], utils.format_rate(x[1]))
+++ /dev/null
-import utils
-from money import money
-
-class tax_collector(object):
- def __init__(self):
- # These four accumulate and then clear every year (i.e. every call
- # to record_taxes_paid_and_reset_for_next_year)
- self.ordinary_income = money(0)
- self.short_term_gains = money(0)
- self.dividends_and_long_term_gains = money(0)
- self.roth_income = money(0)
-
- # The rest of these aggregate and are used as part of the final stats.
- self.total_tax_bill = money(0)
- self.total_ordinary_income = money(0)
- self.total_short_term_gains = money(0)
- self.total_dividends_and_long_term_gains = money(0)
- self.total_roth_income = money(0)
- self.total_aggregate_income = money(0)
-
- def record_taxes_paid_and_reset_for_next_year(self,
- taxes_paid):
- assert taxes_paid >= 0, "You can't pay negative taxes"
- self.total_tax_bill += taxes_paid
- self.total_aggregate_income += self.get_total_income()
- assert self.total_aggregate_income >= 0, "Accumulator should be >= 0"
- self.ordinary_income = money(0)
- self.short_term_gains = money(0)
- self.dividends_and_long_term_gains = money(0)
- self.roth_income = money(0)
-
- def record_ordinary_income(self, amount):
- assert amount >= 0, "Income should be non-negative; got %s" % amount
- self.ordinary_income += amount
- self.total_ordinary_income += amount
-
- def record_short_term_gain(self, amount):
- assert amount >= 0, "Income should be non-negative; got %s" % amount
- self.short_term_gains += amount
- self.total_short_term_gains += amount
-
- def record_dividend_or_long_term_gain(self, amount):
- assert amount >= 0, "Income should be non-negative; got %s" % amount
- self.dividends_and_long_term_gains += amount
- self.total_dividends_and_long_term_gains += amount
-
- def record_roth_income(self, amount):
- assert amount >= 0, "Income should be non-negative; got %s" % amount
- self.roth_income += amount
- self.total_roth_income += amount
-
- def approximate_taxes(self,
- standard_deduction,
- ordinary_income_tax_brackets,
- dividends_and_long_term_gains_brackets):
- assert standard_deduction >= 0, "Standard deduction should be non-negative"
- taxes_due = money(0)
-
- # Handle ordinary income:
- ordinary_income = (self.ordinary_income +
- self.short_term_gains -
- standard_deduction)
- if ordinary_income < 0:
- ordinary_income = money(0)
- taxes_due += ordinary_income_tax_brackets.compute_taxes_for_income(
- ordinary_income)
-
- # Handle dividends and long term gains:
- taxes_due += dividends_and_long_term_gains_brackets.compute_taxes_for_income(
- self.dividends_and_long_term_gains)
-
- # Assume Roth money is still available tax free in the future.
- assert taxes_due >= 0, "Computed negative taxes?!"
- return taxes_due
-
- def how_many_more_dollars_can_we_earn_without_changing_tax_rate(
- self,
- federal_ordinary_income_tax_brackets):
- """Return number of ordinary income dollars we can make without
- changing this year's tax rate. Note: this may return None
- which actually indicates something more like 'infinite since
- you're already at the top tax bracket.'"""
- income = self.ordinary_income
- if income <= 1: income = 2
- b = federal_ordinary_income_tax_brackets.get_bracket_above_income(
- income)
- if b[0] is not None:
- delta = b[0] - income
- return delta
- return 0
-
- def get_total_income(self):
- return (self.ordinary_income +
- self.short_term_gains +
- self.dividends_and_long_term_gains +
- self.roth_income)
-
- def dump_final_report(self):
- print " Taxes and income:"
- print " {:<50}: {:>14}".format("Total aggregate income",
- self.total_aggregate_income)
- print " ...{:<47}: {:>14}".format("Ordinary income",
- self.total_ordinary_income)
- print " ...{:<47}: {:>14}".format("Income from short term gains",
- self.total_short_term_gains)
- print " ...{:<47}: {:>14}".format("Income from dividends and long term gains",
- self.total_dividends_and_long_term_gains)
- print " ...{:<47}: {:>14}".format("Roth income",
- self.total_roth_income)
- print " {:<50}: {:>14}".format("Total taxes paid",
- self.total_tax_bill)
- overall_tax_rate = float(self.total_tax_bill) / float(self.total_aggregate_income)
- print " {:<50}: {:>14}".format("Effective tax rate",
- utils.format_rate(overall_tax_rate))
--- /dev/null
+#!/usr/bin/env python3
+
+from typing import List, Optional, Tuple
+
+from type.money import Money
+from type.rate import Rate
+import string_utils as su
+
+
+PESSIMISTIC_FEDERAL_INCOME_TAX_BRACKETS = [
+ (Money(612351), Rate(0.70)),
+ (Money(408201), Rate(0.55)),
+ (Money(321451), Rate(0.45)),
+ (Money(168401), Rate(0.35)),
+ (Money( 78951), Rate(0.26)),
+ (Money( 19401), Rate(0.18)),
+ (Money( 1), Rate(0.13)),
+]
+
+CURRENT_FEDERAL_INCOME_TAX_BRACKETS = [
+ (Money(612351), Rate(0.37)),
+ (Money(408201), Rate(0.35)),
+ (Money(321451), Rate(0.32)),
+ (Money(168401), Rate(0.24)),
+ (Money( 78951), Rate(0.22)),
+ (Money( 19401), Rate(0.12)),
+ (Money( 1), Rate(0.10)),
+]
+
+CURRENT_LONG_TERM_GAIN_FEDERAL_TAX_BRACKETS = [
+ (Money(488851), Rate(0.20)),
+ (Money( 78751), Rate(0.15)),
+ (Money( 1), Rate(0.00)),
+]
+
+PESSIMISTIC_LONG_TERM_GAIN_FEDERAL_TAX_BRACKETS = (
+ PESSIMISTIC_FEDERAL_INCOME_TAX_BRACKETS)
+
+
+federal_standard_deduction_dollars = Money(24800) # Federal tax standard deduction
+federal_ordinary_income_tax_brackets = PESSIMISTIC_FEDERAL_INCOME_TAX_BRACKETS
+federal_dividends_and_long_term_gains_brackets = CURRENT_LONG_TERM_GAIN_FEDERAL_TAX_BRACKETS
+
+
+class TaxCollector(object):
+ def __init__(
+ self,
+ ordinary_income_brackets,
+ dividends_and_long_term_gains_brackets,
+ ):
+ self.ordinary_income_brackets = ordinary_income_brackets
+ self.dividends_and_long_term_gains_brackets = dividends_and_long_term_gains_brackets
+
+ # These four accumulate and then clear every year (i.e. every call
+ # to record_taxes_paid_and_reset_for_next_year)
+ self.ordinary_income = Money(0)
+ self.short_term_gains = Money(0)
+ self.dividends_and_long_term_gains = Money(0)
+ self.roth_income = Money(0)
+
+ # The rest of these aggregate and are used as part of the final stats.
+ self.total_tax_bill = Money(0)
+ self.total_ordinary_income = Money(0)
+ self.total_short_term_gains = Money(0)
+ self.total_dividends_and_long_term_gains = Money(0)
+ self.total_roth_income = Money(0)
+ self.total_aggregate_income = Money(0)
+ self.max_annual_income = None
+ self.max_annual_income_year = None
+
+ def record_taxes_paid_and_reset_for_next_year(self, current_year: int):
+ self.total_tax_bill += self.approximate_annual_taxes()
+ income = self.get_total_annual_income()
+ if self.max_annual_income is None or income > self.max_annual_income:
+ self.max_annual_income = income
+ self.max_annual_income_year = current_year
+ self.total_aggregate_income += income
+ self.ordinary_income = Money(0)
+ self.short_term_gains = Money(0)
+ self.dividends_and_long_term_gains = Money(0)
+ self.roth_income = Money(0)
+
+ def record_ordinary_income(self, amount: Money):
+ self.ordinary_income += amount
+ self.total_ordinary_income += amount
+
+ def record_short_term_gain(self, amount: Money):
+ self.short_term_gains += amount
+ self.total_short_term_gains += amount
+
+ def record_dividend_or_long_term_gain(self, amount: Money):
+ self.dividends_and_long_term_gains += amount
+ self.total_dividends_and_long_term_gains += amount
+
+ def record_roth_income(self, amount: Money):
+ self.roth_income += amount
+ self.total_roth_income += amount
+
+ def approximate_annual_taxes(self):
+ taxes_due = Money(0)
+
+ # Handle ordinary income:
+ ordinary_income = (self.ordinary_income +
+ self.short_term_gains -
+ federal_standard_deduction_dollars)
+ if ordinary_income < 0:
+ ordinary_income = 0
+ taxes_due += self.compute_taxes_on_income_using_brackets(
+ ordinary_income,
+ self.ordinary_income_brackets
+ )
+
+ # Handle dividends and long term gains:
+ taxes_due += self.compute_taxes_on_income_using_brackets(
+ self.dividends_and_long_term_gains,
+ self.dividends_and_long_term_gains_brackets
+ )
+
+ # Assume Roth money is still available tax free in the future.
+ return taxes_due
+
+ def compute_taxes_on_income_using_brackets(
+ self,
+ income: Money,
+ brackets: List[Tuple[Money, Rate]]
+ ) -> Money:
+ taxes_due = Money(0)
+ while income > Money(1.0):
+ rate = Rate(0.0)
+ threshold = 0
+ for bracket in brackets:
+ # Bracket is a tuple of (monetary_threshold,
+ # tax_rate). So bracket[0] is a dollar amount and
+ # bracket[1] is a tax rate. e.g. ( 250000, 0.20 )
+ # indicates that every dollar you earn over 250K is
+ # taxed at 20%.
+ if (income > bracket[0] and rate < bracket[1]):
+ threshold = bracket[0]
+ rate = bracket[1]
+ taxed_amount = income - threshold
+ taxes_due += taxed_amount * rate
+ income = threshold
+ return taxes_due
+
+ def how_many_more_dollars_can_we_earn_without_changing_tax_rate(
+ self
+ ) -> Optional[Money]:
+ """Return number of ordinary income dollars we can make without
+ changing this year's tax rate. Note: this may return None
+ which actually indicates something more like 'infinite since
+ you're already at the top tax bracket.'"""
+ threshold = None
+ rate = Rate(999.0)
+ income = self.ordinary_income
+
+ # Kinda hacky but if there's been no ordinary income this year
+ # this method was saying "oh, well, you can earn one dollar
+ # before your tax rate goes from zero to something non-zero."
+ # This is correct but not useful -- this method is used to
+ # opportunistically shuffle pretax money into Roth. So let's
+ # assume we've earned at least one dollar of ordinary income
+ # this year and work from there.
+ if income <= Money(0):
+ income = Money(1.0)
+
+ # Walk the income tax brackets and look for the one just above
+ # the one this year's income is activating.
+ for bracket in self.ordinary_income_brackets:
+ if (bracket[0] > income and bracket[1] < rate):
+ threshold = bracket[0]
+ rate = bracket[1]
+
+ # If we found the bracket just above here, use it to compute a
+ # delta.
+ if threshold is not None:
+ assert threshold > income
+ delta = threshold - income
+ return delta
+ return None
+
+ def get_total_annual_income(self) -> Money:
+ return (
+ self.ordinary_income +
+ self.short_term_gains +
+ self.dividends_and_long_term_gains +
+ self.roth_income
+ )
+
+ def approximate_annual_tax_rate(self) -> Rate:
+ total_income = self.get_total_annual_income()
+ total_taxes = self.approximate_annual_taxes()
+ return Rate(
+ multiplier=float(total_taxes) / float(total_income)
+ )
+
+ def __repr__(self):
+ with su.SprintfStdout() as ret:
+ print("Taxes and Income:")
+ print(" %-50s: %18s" % ("Total aggregate income",
+ self.total_aggregate_income))
+ print(" ...%-47s: %18s" % ("Ordinary income",
+ self.total_ordinary_income))
+ print(" ...%-47s: %18s" % ("Income from short term gains",
+ self.total_short_term_gains))
+ print(" ...%-47s: %18s" % ("Income from dividends and long term gains",
+ self.total_dividends_and_long_term_gains))
+ print(" ...%-47s: %18s" % ("Roth income",
+ self.total_roth_income))
+ print(" %-50s: %18s (%s)" % ("Maximum annual income",
+ self.max_annual_income,
+ f'in {self.max_annual_income_year}'))
+ print(" %-50s: %18s" % ("Total taxes paid",
+ self.total_tax_bill))
+ overall_tax_rate = Rate(
+ float(self.total_tax_bill) / float(self.total_aggregate_income)
+ )
+ print(" %-50s: %18s" % ("Effective tax rate",
+ overall_tax_rate), end='')
+ return ret()
--- /dev/null
+#!/usr/bin/env python3
+
+import copy
+from typing import Iterable
+
+import simulation
+import simulation_params
+import simulation_results
+
+
+def run_multiple_trials(
+ *,
+ params: simulation_params.SimulationParameters,
+ num_trials: int
+) -> Iterable[simulation_results.SimulationResults]:
+ results = []
+ for x in range(0, num_trials):
+ print(f'{x} / {num_trials}', end='\r')
+ sim = simulation.Simulation(copy.deepcopy(params))
+ results.append(
+ sim.simulate(simulation.Verbosity.SILENT)
+ )
+ return results
+++ /dev/null
-
-# Global helper functions
-def truncate(n, decimals=2):
- """Truncate a float to a particular number of decimals."""
- assert decimals > 0 and decimals < 10, "Decimals is weird"
- multiplier = 10 ** decimals
- return int(n * multiplier) / multiplier
-
-def format_rate(rate):
- """Format a multiplier nee rate to look nice."""
- if rate >= 1.0:
- return format_rate(rate - 1.0)
- else:
- return "{:<}%".format(round(rate * 100, 3))
-
-def convert_rate_to_multiplier(rate):
- return 1.0 + (rate / 100)