From bdfec6b6c410001c5b353dcfd773c8ab7c33e67c Mon Sep 17 00:00:00 2001 From: Scott Gasch Date: Sat, 11 Jan 2020 19:08:33 -0800 Subject: [PATCH] Initial commit --- accounts.py | 425 +++++++++++++++++++++++++++++++++++++++++++++++ constants.py | 31 ++++ parameters.py | 151 +++++++++++++++++ retire.py | 288 ++++++++++++++++++++++++++++++++ secrets.py | 2 + tax_brackets.py | 69 ++++++++ tax_collector.py | 105 ++++++++++++ utils.py | 14 ++ 8 files changed, 1085 insertions(+) create mode 100644 accounts.py create mode 100644 constants.py create mode 100644 parameters.py create mode 100644 retire.py create mode 100644 secrets.py create mode 100644 tax_brackets.py create mode 100644 tax_collector.py create mode 100644 utils.py diff --git a/accounts.py b/accounts.py new file mode 100644 index 0000000..7867d21 --- /dev/null +++ b/accounts.py @@ -0,0 +1,425 @@ +import constants +import utils + +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 + 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): + 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): + 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): + pass + + def has_roth(self): + pass + + def dump_final_report(self): + 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.""" + self.roth = roth_amount_subset + self.pretax = total_balance - roth_amount_subset + self.total_roth_withdrawals = 0 + self.total_pretax_withdrawals = 0 + self.total_investment_gains = 0 + self.total_roth_conversions = 0 + self.initial_balance = self.get_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.""" + 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 + print "## Satisfying %s from %s with Roth money." % (utils.format_money(roth_part), self.name) + taxes.record_roth_income(roth_part) + + self.pretax -= pretax_part + self.total_pretax_withdrawals += pretax_part + print "## Satisfying %s from %s with pre-tax money." % (utils.format_money(pretax_part), self.name) + taxes.record_ordinary_income(pretax_part) + + def deposit(self, amount): + self.pretax_part += amount + + def appreciate(self, multiplier): + """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 *= 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): + if amount <= 0: + return 0 + if self.pretax >= amount: + self.roth += amount + self.pretax -= amount + self.total_roth_conversions += amount + print "## Executed pre-tax --> Roth conversion of %s in %s" % ( + utils.format_money(amount), + self.get_name()) + return amount + elif self.pretax > 0: + actual_amount = self.pretax + self.roth += actual_amount + self.pretax = 0 + self.total_roth_conversions += actual_amount + print "## Executed pre-tax --> Roth conversion of %s in %s" % ( + utils.format_money(actual_amount), + self.get_name()) + return actual_amount + return 0 + + def dump_final_report(self): + super(age_restricted_tax_deferred_account, self).dump_final_report() + print " {:<50}: {:>14}".format("Initial balance", + utils.format_money(self.initial_balance)) + print " {:<50}: {:>14}".format("Ending balance", + utils.format_money(self.get_balance())) + print " {:<50}: {:>14}".format("Total investment gains", + utils.format_money(self.total_investment_gains)) + print " {:<50}: {:>14}".format("Total Roth withdrawals", + utils.format_money(self.total_roth_withdrawals)) + print " {:<50}: {:>14}".format("Total pre-tax withdrawals", + utils.format_money(self.total_pretax_withdrawals)) + print " {:<50}: {:>14}".format("Total pre-tax converted to Roth", + utils.format_money(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.""" + age_restricted_tax_deferred_account.__init__( + self, total_balance, 0, 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): + 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.""" + self.cost_basis = float(cost_basis) + self.short_term_gain = 0.0 + self.long_term_gain = float(total_balance - cost_basis) + self.total_cost_basis_withdrawals = 0 + self.total_long_term_gain_withdrawals = 0 + self.total_short_term_gain_withdrawals = 0 + self.total_investment_gains = 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).""" + 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." % (utils.format_money(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 > (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." % ( + utils.format_money(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)." % ( + utils.format_money(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." % ( + utils.format_money(amount), + self.name) + taxes.record_short_term_gain(amount) + + def deposit(self, amount): + 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): + return 0 + + def dump_final_report(self): + super(brokerage_account, self).dump_final_report() + print " {:<50}: {:>14}".format("Initial balance", + utils.format_money(self.initial_balance)) + print " {:<50}: {:>14}".format("Ending balance", + utils.format_money(self.get_balance())) + print " {:<50}: {:>14}".format("Total investment gains", + utils.format_money(self.total_investment_gains)) + print " {:<50}: {:>14}".format("Total cost basis withdrawals", + utils.format_money(self.total_cost_basis_withdrawals)) + print " {:<50}: {:>14}".format("Total long term gain withdrawals", + utils.format_money(self.total_long_term_gain_withdrawals)) + print " {:<50}: {:>14}".format("Total short term gain withdrawals", + utils.format_money(self.total_short_term_gain_withdrawals)) diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..ab86887 --- /dev/null +++ b/constants.py @@ -0,0 +1,31 @@ + +# Consts +DEFAULT = 0 +SCOTT = 1 +LYNN = 2 + +PESSIMISTIC_FEDERAL_INCOME_TAX_BRACKETS = [ + [ 612351, 0.50 ], + [ 408201, 0.45 ], + [ 321451, 0.35 ], + [ 168401, 0.25 ], + [ 78951, 0.22 ], + [ 19401, 0.15 ], + [ 1, 0.12 ] +] +CURRENT_FEDERAL_INCOME_TAX_BRACKETS = [ + [ 612351, 0.37 ], + [ 408201, 0.35 ], + [ 321451, 0.32 ], + [ 168401, 0.24 ], + [ 78951, 0.22 ], + [ 19401, 0.12 ], + [ 1, 0.10 ] +] +CURRENT_LONG_TERM_GAIN_FEDERAL_TAX_BRACKETS = [ + [ 488851, 0.20 ], + [ 78751, 0.15 ], + [ 1, 0.00 ] +] +PESSIMISTIC_LONG_TERM_GAIN_FEDERAL_TAX_BRACKETS = ( + PESSIMISTIC_FEDERAL_INCOME_TAX_BRACKETS) diff --git a/parameters.py b/parameters.py new file mode 100644 index 0000000..899545b --- /dev/null +++ b/parameters.py @@ -0,0 +1,151 @@ +import constants +import utils +from tax_brackets import tax_brackets + +class parameters(object): + """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 = 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 + # + self.social_security_age = [ 0, 62, 70 ] + self.initial_social_security_dollars = [ 0, 15000, 25000 ] + + # 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 with_initial_annual_expenses(self, expenses): + self.initial_annual_expenses = expenses + return self + + def get_initial_annual_expenses(self): + return self.initial_annual_expenses + + def with_average_inflation_multiplier(self, multiplier): + self.inflation_multiplier = multiplier + return self + + def get_average_inflation_multiplier(self): + return self.inflation_multiplier + + def with_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 with_average_investment_return_multiplier(self, multiplier): + self.investment_multiplier = multiplier + return self + + def get_average_investment_return_multiplier(self): + return self.investment_multiplier + + def with_initial_social_security_age_and_benefits(self, + person, + age, + amount): + self.social_security_age[person] = age + 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 with_federal_standard_deduction(self, deduction): + self.federal_standard_deduction_dollars = deduction + return self + + def get_federal_standard_deduction(self): + return self.federal_standard_deduction_dollars + + def with_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 with_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", + utils.format_money(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", + utils.format_money(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", + utils.format_money(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." diff --git a/retire.py b/retire.py new file mode 100644 index 0000000..423e254 --- /dev/null +++ b/retire.py @@ -0,0 +1,288 @@ +#!/usr/local/bin/python + +import sys + +from accounts import * +import constants +from parameters import parameters +from tax_brackets import tax_brackets +from tax_collector import tax_collector +import utils +import secrets + +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 + + 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 = 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, 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(): + if x.get_balance() >= amount: + print "## Withdrawing %s from %s" % (utils.format_money(amount), x.get_name()) + x.withdraw(amount, taxes) + return + + elif x.get_balance() > 0 and x.get_balance() < amount: + money_available = x.get_balance() + print "## Withdrawing %s from %s (and exhausting the account)" % (utils.format_money(money_available), x.get_name()) + x.withdraw(money_available, 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: + print "## Withdrawing %s from %s" % (utils.format_money(amount), x.get_name()) + x.withdraw(amount, taxes) + return + + elif x.get_balance() > 0 and x.get_balance() < amount: + money_available = x.get_balance() + print "## Withdrawing %s from %s (and exhausting the account)" % (utils.format_money(money_available), x.get_name()) + x.withdraw(money_available, 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: + print "## Withdrawing %s from %s" % (utils.format_money(amount), x.get_name()) + x.withdraw(amount, taxes) + return + + elif x.get_balance() > 0 and x.get_balance() < amount: + money_available = x.get_balance() + print "## Withdrawing %s from %s (and exhausting the account)" % (utils.format_money(money_available), x.get_name()) + x.withdraw(money_available, 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: + print "## Withdrawing %s from %s" % (utils.format_money(amount), x.get_name()) + x.withdraw(amount, taxes) + return + + elif x.get_balance() > 0 and x.get_balance() < amount: + money_available = x.get_balance() + print "## Withdrawing %s from %s (and exhausting the account)" % (utils.format_money(money_available), x.get_name()) + x.withdraw(money_available, 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: + print "## Withdrawing %s from %s" % (utils.format_money(amount), x.get_name()) + x.withdraw(amount, taxes) + return + + elif x.get_balance() > 0 and x.get_balance() < amount: + money_available = x.get_balance() + print "## Withdrawing %s from %s (and exhausting the account)" % (utils.format_money(money_available), x.get_name()) + x.withdraw(money_available, taxes) + amount -= money_available + raise Exception("Unable to find enough money this year!") + + 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 = 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) + money_converted += amount + desired_conversion_amount -= amount + + 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, + utils.format_money(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 = 0 + for x in self.accounts: + total += x.get_balance() + print "{:<50}: {:>14}".format(x.get_name(), + utils.format_money(x.get_balance())) + print "{:<50}: {:>14}\n".format("TOTAL", utils.format_money(total)) + + def dump_final_report(self, taxes): + print "\nAGGREGATE STATS FINAL REPORT:" + for x in self.accounts: + x.dump_final_report() + taxes.dump_final_report() + + 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 + + # Computed money needed this year based on inflated budget. + money_needed = 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 = 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." % utils.format_money(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" % utils.format_money(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 + + # 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 = float(taxes_due) / float(total_income) + + if tax_rate <= 0.14 and self.year <= 2035: + self.do_opportunistic_roth_conversions(taxes) + + # These conversions will affect taxes due. Recompute + # them now. + 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 = float(taxes_due) / float(total_income) + + # 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)" % ( + utils.format_money(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() + adjusted_annual_expenses *= inflation_multiplier + for x in self.accounts: + x.appreciate(self.params.get_average_investment_return_multiplier()) + if self.scott_age >= self.params.get_initial_social_security_age(constants.SCOTT): + adjusted_scott_annual_social_security_dollars *= self.params.get_average_social_security_multiplier() + if self.lynn_age >= self.params.get_initial_social_security_age(constants.LYNN): + adjusted_lynn_annual_social_security_dollars *= self.params.get_average_social_security_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) + except: + print "Ran out of money!!!" + pass + + finally: + self.dump_final_report(taxes) + +# main +params = parameters().with_default_values() +accounts = secrets.accounts +s = simulation(params, accounts) +s.run() diff --git a/secrets.py b/secrets.py new file mode 100644 index 0000000..dba2524 --- /dev/null +++ b/secrets.py @@ -0,0 +1,2 @@ + +accounts = [ <<< your accounts >>> ] diff --git a/tax_brackets.py b/tax_brackets.py new file mode 100644 index 0000000..29b0079 --- /dev/null +++ b/tax_brackets.py @@ -0,0 +1,69 @@ +import utils + +class tax_brackets: + """A class to represent tax brackets and some operations on them.""" + + def __init__(self, brackets): + self.brackets = brackets + + def compute_taxes_for_income(self, income): + """Compute the tax bill for income given our brackets.""" + taxes_due = 0 + while income > 1: + (threshold, rate) = self.get_bracket_for_income(income) + taxes_due += (income - threshold) * rate + income = threshold + return taxes_due + + def get_bracket_for_income(self, income): + """Return the bracket that the last dollar of income was in.""" + 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.""" + 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.""" + 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.""" + 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): + for x in self.brackets: + print "{:<20} -> {:<3}".format(utils.format_money(x[0]), + utils.format_rate(x[1])) diff --git a/tax_collector.py b/tax_collector.py new file mode 100644 index 0000000..09dc3c7 --- /dev/null +++ b/tax_collector.py @@ -0,0 +1,105 @@ +import utils + +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 = 0 + self.short_term_gains = 0 + self.dividends_and_long_term_gains = 0 + self.roth_income = 0 + + # The rest of these aggregate and are used as part of the final stats. + self.total_tax_bill = 0 + self.total_ordinary_income = 0 + self.total_short_term_gains = 0 + self.total_dividends_and_long_term_gains = 0 + self.total_roth_income = 0 + self.total_aggregate_income = 0 + + def record_taxes_paid_and_reset_for_next_year(self, + taxes_paid): + self.total_tax_bill += taxes_paid + self.total_aggregate_income += self.get_total_income() + self.ordinary_income = 0 + self.short_term_gains = 0 + self.dividends_and_long_term_gains = 0 + self.roth_income = 0 + + def record_ordinary_income(self, amount): + self.ordinary_income += amount + self.total_ordinary_income += amount + + def record_short_term_gain(self, amount): + self.short_term_gains += amount + self.total_short_term_gains += amount + + def record_dividend_or_long_term_gain(self, amount): + self.dividends_and_long_term_gains += amount + self.total_dividends_and_long_term_gains += amount + + def record_roth_income(self, 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): + taxes_due = 0 + + # Handle ordinary income: + ordinary_income = (self.ordinary_income + + self.short_term_gains - + standard_deduction) + if ordinary_income < 0: + ordinary_income = 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. + 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", + utils.format_money(self.total_aggregate_income)) + print " ...{:<47}: {:>14}".format("Ordinary income", + utils.format_money(self.total_ordinary_income)) + print " ...{:<47}: {:>14}".format("Income from short term gains", + utils.format_money(self.total_short_term_gains)) + print " ...{:<47}: {:>14}".format("Income from dividends and long term gains", + utils.format_money(self.total_dividends_and_long_term_gains)) + print " ...{:<47}: {:>14}".format("Roth income", + utils.format_money(self.total_roth_income)) + print " {:<50}: {:>14}".format("Total taxes paid", + utils.format_money(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/utils.py b/utils.py new file mode 100644 index 0000000..a9cd2f0 --- /dev/null +++ b/utils.py @@ -0,0 +1,14 @@ + +# Global helper functions +def truncate(n, decimals=2): + multiplier = 10 ** decimals + return int(n * multiplier) / multiplier + +def format_money(number): + return ("${:,}".format(truncate(number))) + +def format_rate(rate): + if rate >= 1.0: + return format_rate(rate - 1.0) + else: + return "{:<}%".format(round(rate * 100, 3)) -- 2.46.1