From: Scott Gasch Date: Sat, 9 Apr 2022 15:37:41 +0000 (-0700) Subject: Updated. X-Git-Url: https://wannabe.guru.org/gitweb/?a=commitdiff_plain;h=96baff1c1267bf9012586e7c5057dceb30dc494c;p=retire.git Updated. --- diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 18edfe2..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.pyc -*secret* diff --git a/account.py b/account.py new file mode 100644 index 0000000..78035a3 --- /dev/null +++ b/account.py @@ -0,0 +1,417 @@ +#!/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)) diff --git a/accounts.py b/accounts.py deleted file mode 100644 index 2ff804d..0000000 --- a/accounts.py +++ /dev/null @@ -1,428 +0,0 @@ -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) diff --git a/constants.py b/constants.py deleted file mode 100644 index d31b041..0000000 --- a/constants.py +++ /dev/null @@ -1,36 +0,0 @@ -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) diff --git a/data.py b/data.py new file mode 100644 index 0000000..d2cddb8 --- /dev/null +++ b/data.py @@ -0,0 +1,189 @@ +#!/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)), +} diff --git a/money.py b/money.py deleted file mode 100644 index 848d972..0000000 --- a/money.py +++ /dev/null @@ -1,154 +0,0 @@ -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)) diff --git a/parameters.py b/parameters.py deleted file mode 100644 index 1d6443c..0000000 --- a/parameters.py +++ /dev/null @@ -1,332 +0,0 @@ -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 - diff --git a/person.py b/person.py new file mode 100644 index 0000000..ae86ae1 --- /dev/null +++ b/person.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 + +import enum + + +class Person(enum.Enum): + UNKNOWN = 0 + SCOTT = 1 + LYNN = 2 + SHARED = 3 diff --git a/result_summarizer.py b/result_summarizer.py new file mode 100644 index 0000000..ca0fb72 --- /dev/null +++ b/result_summarizer.py @@ -0,0 +1,57 @@ +#!/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')) diff --git a/retire.py b/retire.py index fc68655..4282da7 100755 --- a/retire.py +++ b/retire.py @@ -1,287 +1,74 @@ -#!/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.... + +] + + +@bootstrap.initialize +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() diff --git a/returns_and_expenses.py b/returns_and_expenses.py new file mode 100644 index 0000000..3421429 --- /dev/null +++ b/returns_and_expenses.py @@ -0,0 +1,120 @@ +#!/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)})' diff --git a/secrets.py b/secrets.py deleted file mode 100644 index cedeec5..0000000 --- a/secrets.py +++ /dev/null @@ -1,4 +0,0 @@ -import accounts - -def get_starting_account_list: - return [ <<< your accounts >>> ] diff --git a/simulation.py b/simulation.py new file mode 100644 index 0000000..6cba37f --- /dev/null +++ b/simulation.py @@ -0,0 +1,381 @@ +#!/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 diff --git a/simulation_params.py b/simulation_params.py new file mode 100644 index 0000000..973b528 --- /dev/null +++ b/simulation_params.py @@ -0,0 +1,59 @@ +#!/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, +) diff --git a/simulation_results.py b/simulation_results.py new file mode 100644 index 0000000..4f4fff2 --- /dev/null +++ b/simulation_results.py @@ -0,0 +1,75 @@ +#!/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() diff --git a/tax_brackets.py b/tax_brackets.py deleted file mode 100644 index b2bd4a7..0000000 --- a/tax_brackets.py +++ /dev/null @@ -1,76 +0,0 @@ -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])) diff --git a/tax_collector.py b/tax_collector.py deleted file mode 100644 index 7680764..0000000 --- a/tax_collector.py +++ /dev/null @@ -1,114 +0,0 @@ -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)) diff --git a/taxman.py b/taxman.py new file mode 100644 index 0000000..44ab4ee --- /dev/null +++ b/taxman.py @@ -0,0 +1,219 @@ +#!/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() diff --git a/trials.py b/trials.py new file mode 100644 index 0000000..71f9d1e --- /dev/null +++ b/trials.py @@ -0,0 +1,23 @@ +#!/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 diff --git a/utils.py b/utils.py deleted file mode 100644 index eddefdb..0000000 --- a/utils.py +++ /dev/null @@ -1,17 +0,0 @@ - -# 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)