Initial commit
authorScott Gasch <[email protected]>
Sun, 12 Jan 2020 03:08:33 +0000 (19:08 -0800)
committerScott Gasch <[email protected]>
Sun, 12 Jan 2020 03:08:33 +0000 (19:08 -0800)
accounts.py [new file with mode: 0644]
constants.py [new file with mode: 0644]
parameters.py [new file with mode: 0644]
retire.py [new file with mode: 0644]
secrets.py [new file with mode: 0644]
tax_brackets.py [new file with mode: 0644]
tax_collector.py [new file with mode: 0644]
utils.py [new file with mode: 0644]

diff --git a/accounts.py b/accounts.py
new file mode 100644 (file)
index 0000000..7867d21
--- /dev/null
@@ -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 (file)
index 0000000..ab86887
--- /dev/null
@@ -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 (file)
index 0000000..899545b
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..dba2524
--- /dev/null
@@ -0,0 +1,2 @@
+
+accounts = [ <<< your accounts >>> ]
diff --git a/tax_brackets.py b/tax_brackets.py
new file mode 100644 (file)
index 0000000..29b0079
--- /dev/null
@@ -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 (file)
index 0000000..09dc3c7
--- /dev/null
@@ -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 (file)
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))