Updated. master
authorScott Gasch <[email protected]>
Sat, 9 Apr 2022 15:37:41 +0000 (08:37 -0700)
committerScott Gasch <[email protected]>
Sat, 9 Apr 2022 15:37:41 +0000 (08:37 -0700)
20 files changed:
.gitignore [deleted file]
account.py [new file with mode: 0644]
accounts.py [deleted file]
constants.py [deleted file]
data.py [new file with mode: 0644]
money.py [deleted file]
parameters.py [deleted file]
person.py [new file with mode: 0644]
result_summarizer.py [new file with mode: 0644]
retire.py
returns_and_expenses.py [new file with mode: 0644]
secrets.py [deleted file]
simulation.py [new file with mode: 0644]
simulation_params.py [new file with mode: 0644]
simulation_results.py [new file with mode: 0644]
tax_brackets.py [deleted file]
tax_collector.py [deleted file]
taxman.py [new file with mode: 0644]
trials.py [new file with mode: 0644]
utils.py [deleted file]

diff --git a/.gitignore b/.gitignore
deleted file mode 100644 (file)
index 18edfe2..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-*.pyc
-*secret*
diff --git a/account.py b/account.py
new file mode 100644 (file)
index 0000000..78035a3
--- /dev/null
@@ -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 (file)
index 2ff804d..0000000
+++ /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 (file)
index d31b041..0000000
+++ /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 (file)
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 (file)
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 (file)
index 1d6443c..0000000
+++ /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 (file)
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 (file)
index 0000000..ca0fb72
--- /dev/null
@@ -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'))
index fc68655dcd63e8b1c96187090317b152472483a6..4282da73b4e90e919d927fd6da1da57ce592cb43 100755 (executable)
--- a/retire.py
+++ b/retire.py
-#!/usr/local/bin/python
-
-import sys
-import traceback
-
-from accounts import *
-import constants
-from parameters import *
-from tax_brackets import tax_brackets
-from tax_collector import tax_collector
-import utils
-import secrets
-from money import money
-
-class simulation(object):
-    def __init__(self, parameters, accounts):
-        """Initialize simulation parameters and starting account balances"""
-        self.params = parameters
-        self.accounts = accounts
-        self.lynn_age = 0
-        self.scott_age = 0
-        self.alex_age = 0
-        self.year = 0
-        self.max_net_worth = 0
-
-    def do_rmd_withdrawals(self, taxes):
-        """Determine if any account that has RMDs will require someone to
-           take money out of it this year and, if so, how much.  Then do
-           the withdrawal."""
-        total_withdrawn = money(0)
-        for x in self.accounts:
-            if x.has_rmd() and x.get_owner() == constants.SCOTT:
-                rmd = x.do_rmd_withdrawal(self.scott_age, taxes)
-                total_withdrawn += rmd
-            elif x.has_rmd() and x.get_owner() == constants.LYNN:
-                rmd = x.do_rmd_withdrawal(self.lynn_age, taxes)
-                total_withdrawn += rmd
-        return total_withdrawn
-
-    def go_find_money(self, amount_needed, taxes):
-        """Look through accounts and try to find amount using some heuristics
-           about where to withdraw money first."""
-
-        # Try brokerage accounts first
-        for x in self.accounts:
-            if not x.is_age_restricted():
-                amount_to_withdraw = min(amount_needed, x.get_balance())
-                if amount_to_withdraw > 0:
-                    print "## Withdrawing %s from %s" % (amount_to_withdraw,
-                                                         x.get_name())
-                    x.withdraw(amount_to_withdraw, taxes)
-                    amount_needed -= amount_to_withdraw
-                    if amount_needed <= 0: return
-
-        # Next try age restircted accounts
-        for x in self.accounts:
-            if (x.is_age_restricted() and
-                x.has_rmd() and
-                ((x.belongs_to_lynn() and self.lynn_age > 60) or
-                 (x.belongs_to_scott() and self.scott_age > 60))):
-
-                amount_to_withdraw = min(amount_needed, x.get_balance())
-                if amount_to_withdraw > 0:
-                    print "## Withdrawing %s from %s" % (amount_to_withdraw,
-                                                         x.get_name())
-                    x.withdraw(amount_to_withdraw, taxes)
-                    amount_needed -= amount_to_withdraw
-                    if amount_needed <= 0: return
-
-        # Last try Roth accounts
-        for x in self.accounts:
-            if (x.is_age_restricted() and
-                x.has_roth() and
-                ((x.belongs_to_lynn() and self.lynn_age > 60) or
-                 (x.belongs_to_scott() and self.scott_age > 60))):
-
-                amount_to_withdraw = min(amount_needed, x.get_balance())
-                if amount_to_withdraw > 0:
-                    print "## Withdrawing %s from %s" % (amount_to_withdraw,
-                                                         x.get_name())
-                    x.withdraw(amount_to_withdraw, taxes)
-                    amount_needed -= amount_to_withdraw
-                    if amount_needed <= 0: return
-        raise Exception("Unable to find enough money this year, still need %s more!" % amount_needed)
-
-    def get_social_security(self,
-                            scott_annual_social_security_dollars,
-                            lynn_annual_social_security_dollars,
-                            taxes):
-        """Figure out if Scott and/or Lynn are taking social security at
-           their present age in the simulation and, if so, how much their
-           annual benefit should be."""
-        total_benefit = money(0)
-        if self.scott_age >= self.params.get_initial_social_security_age(constants.SCOTT):
-            total_benefit += scott_annual_social_security_dollars
-            taxes.record_ordinary_income(scott_annual_social_security_dollars)
-        if self.lynn_age >= self.params.get_initial_social_security_age(constants.LYNN):
-            total_benefit += lynn_annual_social_security_dollars
-            taxes.record_ordinary_income(lynn_annual_social_security_dollars)
-        return total_benefit
-
-    def do_opportunistic_roth_conversions(self, taxes):
-        """Roll over money from pretax 401(k)s or IRAs into Roth."""
-        desired_conversion_amount = (
-            taxes.how_many_more_dollars_can_we_earn_without_changing_tax_rate(
-                self.params.get_federal_ordinary_income_tax_brackets()))
-        if desired_conversion_amount > 0:
-            money_converted = 0
-            for x in self.accounts:
-                if x.has_roth():
-                    amount = x.do_roth_conversion(desired_conversion_amount, taxes)
-                    money_converted += amount
-                    desired_conversion_amount -= amount
-            if money_converted > 0:
-                return True
-        return False
-
-    def dump_report_header(self):
-        self.params.dump()
-
-    def dump_annual_header(self, money_needed):
-        print "\nYear: %d, estimated annual expenses %s ---------------\n" % (
-            self.year,
-            money_needed)
-        print "Scott is %d, Lynn is %d and Alex is %d.\n" % (
-            self.scott_age, self.lynn_age, self.alex_age)
-
-        # Print out how much money is in each account + overall net worth.
-        total = money(0)
-        for x in self.accounts:
-            total += x.get_balance()
-            print "{:<50}: {:>14}".format(x.get_name(), x.get_balance())
-        print "{:<50}: {:>14}\n".format("TOTAL", total)
-        if self.max_net_worth < total:
-            self.max_net_worth = total
-
-    def dump_final_report(self, taxes):
-        print "\nAGGREGATE STATS FINAL REPORT:"
-        total = money(0)
-        for x in self.accounts:
-            x.dump_final_report()
-            total += x.get_balance()
-        taxes.dump_final_report()
-        print "    {:<50}: {:>14}".format("Max net worth achieved",
-                                          self.max_net_worth)
-        print "==> {:<50}: {:>14}".format("Final net worth of simulation",
-                                          total)
-
-    def run(self):
-        """Run the simulation!"""
-        self.dump_report_header()
-
-        taxes = tax_collector()
-        adjusted_annual_expenses = self.params.get_initial_annual_expenses()
-        adjusted_scott_annual_social_security_dollars = (
-            self.params.get_initial_social_security_benefit(constants.SCOTT))
-        adjusted_lynn_annual_social_security_dollars = (
-            self.params.get_initial_social_security_benefit(constants.LYNN))
-
-
-        try:
-            for self.year in xrange(2020, 2080):
-                self.scott_age = self.year - 1974
-                self.lynn_age = self.year - 1964
-                self.alex_age = self.year - 2005
-                self.params.report_year(self.year)
-
-                # Computed money needed this year based on inflated budget.
-                money_needed = money(adjusted_annual_expenses)
-
-                # When Alex is in college, we need $50K more per year.
-                if self.alex_age > 18 and self.alex_age <= 22:
-                    money_needed += 50000
-
-                self.dump_annual_header(money_needed)
-
-                # Now, figure out how to find money to pay for this year
-                # and how much of it is taxable.
-                total_income = money(0)
-
-                # When we reach a certain age we have to take RMDs from
-                # some accounts.  Handle that here.
-                rmds = self.do_rmd_withdrawals(taxes)
-                if rmds > 0:
-                    print "## Satisfied %s of RMDs from age-restricted accounts." % rmds
-                    total_income += rmds
-                    money_needed -= rmds
-
-                # When we reach a certain age we are eligible for SS
-                # payments.
-                ss = self.get_social_security(
-                    adjusted_scott_annual_social_security_dollars,
-                    adjusted_lynn_annual_social_security_dollars,
-                    taxes)
-                if ss > 0:
-                    print "## Social security paid %s" % ss
-                    total_income += ss
-                    money_needed -= ss
-
-                # If we still need money, try to go find it.
-                if money_needed > 0:
-                    self.go_find_money(money_needed, taxes)
-                    total_income += money_needed
-                    money_needed = 0
-
-                look_for_conversions = True
-                tax_limit = money(25000)
-                while True:
-                    # Maybe do some opportunistic Roth conversions.
-                    taxes_due = taxes.approximate_taxes(
-                        self.params.get_federal_standard_deduction(),
-                        self.params.get_federal_ordinary_income_tax_brackets(),
-                        self.params.get_federal_dividends_and_long_term_gains_income_tax_brackets())
-                    total_income = taxes.get_total_income()
-                    tax_rate = 0.0
-                    if total_income > 0:
-                        tax_rate = float(taxes_due) / float(total_income)
-
-                    if (look_for_conversions and
-                        tax_rate <= 0.14 and
-                        taxes_due < tax_limit and
-                        self.year <= 2036):
-
-                        look_for_conversions = self.do_opportunistic_roth_conversions(taxes)
-                        # because these conversions affect taxes, spin
-                        # once more in the loop and recompute taxes
-                        # due and tax rate.
-                    else:
-                        break
-
-                # Pay taxes_due by going to find more money.  This is a
-                # bit hacky since withdrawing more money to cover taxes
-                # will, in turn, cause taxable income.  But I think taxes
-                # are low enough and this simulation is rough enough that
-                # we can ignore this.
-                if taxes_due > 0:
-                    print "## Estimated federal tax due: %s (this year's tax rate=%s)" % (
-                        taxes_due, utils.format_rate(tax_rate))
-                    self.go_find_money(taxes_due, taxes)
-                else:
-                    print "## No federal taxes due this year!"
-                taxes.record_taxes_paid_and_reset_for_next_year(taxes_due)
-
-                # Inflation and appreciation:
-                #   * Cost of living increases
-                #   * Social security benefits increase
-                #   * Tax brackets are adjusted for inflation
-                inflation_multiplier = self.params.get_average_inflation_multiplier()
-                returns_multiplier = self.params.get_average_investment_return_multiplier()
-                ss_multiplier = self.params.get_average_social_security_multiplier()
-                adjusted_annual_expenses *= inflation_multiplier
-                for x in self.accounts:
-                    x.appreciate(returns_multiplier)
-                if self.scott_age >= self.params.get_initial_social_security_age(constants.SCOTT):
-                    adjusted_scott_annual_social_security_dollars *= ss_multiplier
-                if self.lynn_age >= self.params.get_initial_social_security_age(constants.LYNN):
-                    adjusted_lynn_annual_social_security_dollars *= ss_multiplier
-                self.params.get_federal_ordinary_income_tax_brackets().adjust_with_multiplier(inflation_multiplier)
-                self.params.get_federal_dividends_and_long_term_gains_income_tax_brackets().adjust_with_multiplier(inflation_multiplier)
-            succeeded = True
-        except Exception as e:
-            print "Exception: %s" % e
-            print traceback.print_exc(e)
-            print "Ran out of money!?!"
-            succeeded = False
-
-        finally:
-            self.dump_final_report(taxes)
-            return succeeded
-
-# main
-#params = mutable_default_parameters()
-
-params = None
-accounts = None
-s = None
-for x in xrange(1, 100):
-    del params
-    del accounts
-    del s
-    params = mutable_dynamic_historical_parameters()
-    accounts = secrets.get_starting_account_list()
-    s = simulation(params, accounts)
-    print "====================== Simulation %d ======================" % x
-    if s.run() == False:
-        print "This simulation failed!"
-        sys.exit(0)
+#!/usr/bin/env python3
+
+from typing import List
+
+import bootstrap
+import config
+
+import account
+from person import Person
+import result_summarizer
+import returns_and_expenses as rai
+import simulation
+import simulation_params
+import trials
+from type.money import Money
+
+
+args = config.add_commandline_args(
+    'Retire!',
+    'Args that drive the retirement simulator',
+)
+args.add_argument(
+    '--num_trials',
+    '-n',
+    type=int,
+    default=1,
+    help='How many simulations to run'
+)
+args.add_argument(
+    '--verbosity',
+    '-v',
+    type=int,
+    choices=range(0, 3),
+    default=1,
+    help='How verbose should I be?',
+)
+
+
+# This defines the set of account and their initial balances.
+accounts: List[account.Account] = [
+
+    # Your accounts here....
+
+]
+
+
+def main() -> None:
+    params = simulation_params.DEFAULT_SIMULATION_PARAMS
+    params.initial_account_states = accounts
+    params.returns_and_expenses = rai.GaussianRae()  #rai.HistoricalRaE()
+
+    if config.config['num_trials'] > 1:
+        with simulation.ReportColorizer():
+            print(params)
+        results = trials.run_multiple_trials(
+            params=params,
+            num_trials=config.config['num_trials']
+        )
+        print
+        result_summarizer.summarize_results(results)
+    else:
+        sim = simulation.Simulation(params)
+        results = sim.simulate(
+            simulation.Verbosity(config.config['verbosity'])
+        )
+        if not results.success:
+            print("Unsuccessful")
+            return 1
+        return 0
+
+
+if __name__ == '__main__':
+    main()
diff --git a/returns_and_expenses.py b/returns_and_expenses.py
new file mode 100644 (file)
index 0000000..3421429
--- /dev/null
@@ -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 (file)
index cedeec5..0000000
+++ /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 (file)
index 0000000..6cba37f
--- /dev/null
@@ -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 (file)
index 0000000..973b528
--- /dev/null
@@ -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 (file)
index 0000000..4f4fff2
--- /dev/null
@@ -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 (file)
index b2bd4a7..0000000
+++ /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 (file)
index 7680764..0000000
+++ /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 (file)
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 (file)
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 (file)
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)