From 96baff1c1267bf9012586e7c5057dceb30dc494c Mon Sep 17 00:00:00 2001 From: Scott Gasch Date: Sat, 9 Apr 2022 08:37:41 -0700 Subject: [PATCH] Updated. --- .gitignore | 2 - account.py | 417 ++++++++++++++++++++++++++++++++++++++ accounts.py | 428 ---------------------------------------- constants.py | 36 ---- data.py | 189 ++++++++++++++++++ money.py | 154 --------------- parameters.py | 332 ------------------------------- person.py | 10 + result_summarizer.py | 57 ++++++ retire.py | 361 +++++++-------------------------- returns_and_expenses.py | 120 +++++++++++ secrets.py | 4 - simulation.py | 381 +++++++++++++++++++++++++++++++++++ simulation_params.py | 59 ++++++ simulation_results.py | 75 +++++++ tax_brackets.py | 76 ------- tax_collector.py | 114 ----------- taxman.py | 219 ++++++++++++++++++++ trials.py | 23 +++ utils.py | 17 -- 20 files changed, 1624 insertions(+), 1450 deletions(-) delete mode 100644 .gitignore create mode 100644 account.py delete mode 100644 accounts.py delete mode 100644 constants.py create mode 100644 data.py delete mode 100644 money.py delete mode 100644 parameters.py create mode 100644 person.py create mode 100644 result_summarizer.py create mode 100644 returns_and_expenses.py delete mode 100644 secrets.py create mode 100644 simulation.py create mode 100644 simulation_params.py create mode 100644 simulation_results.py delete mode 100644 tax_brackets.py delete mode 100644 tax_collector.py create mode 100644 taxman.py create mode 100644 trials.py delete mode 100644 utils.py 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) -- 2.46.1