Exemplo n.º 1
0
 def test_time_series_basic(self):
     """ Tests `time_series` with simple input. """
     # start and end are equally weighted:
     timing = Timing({0: 1, 1: 1})
     result = timing.time_series(2)
     # Spreading a value of 2 equally across start and end means
     # allocating 1 at start and 1 at end:
     self.assertEqual(result, {0: 1, 1: 1})
Exemplo n.º 2
0
 def test_normalized_subset(self):
     """ Tests `normalized` for a subset of keys. """
     # start and end are equally weighted:
     timing = Timing({0: 1, 1: 1})
     result = timing.normalized(keys={1})
     # Normalizing just end should yield a value of 1 at that time:
     target = Timing({1: 1})
     self.assertEqual(result, target)
Exemplo n.º 3
0
 def test_time_series_subset(self):
     """ Tests `time_series` for a subset of keys. """
     # start and end are equally weighted:
     timing = Timing({0: 1, 1: 1})
     result = timing.time_series(2, keys={1})
     # Spreading a value of 2 equally across _just end_ means
     # allocating 2 at end:
     self.assertEqual(result, {1: 2})
Exemplo n.º 4
0
 def test_normalized_basic(self):
     """ Tests `normalized` with simple input. """
     # start and end are equally weighted:
     timing = Timing({0: 1, 1: 1})
     result = timing.normalized()
     # start and end are equally weighted, so each should get 0.5
     # when normalized:
     target = Timing({0: 0.5, 1: 0.5})
     self.assertEqual(result, target)
Exemplo n.º 5
0
    def __init__(self,
                 initial_year,
                 name,
                 birth_date,
                 retirement_date=None,
                 gross_income=None,
                 raise_rate=None,
                 spouse=None,
                 tax_treatment=None,
                 payment_timing=None,
                 inputs=None,
                 **kwargs):
        """ Initializes a `Person` object. """
        super().__init__(initial_year=initial_year, inputs=inputs, **kwargs)

        # For numerical optional inputs, ensure an appropriately-typed
        # default value is used:
        if gross_income is None:
            gross_income = self.precision_convert(0)
        if raise_rate is None:
            raise_rate = self.precision_convert(0)

        # For simple, non-property-wrapped attributes, assign directly:
        self.name = name
        if payment_timing is None:
            # Timing is technically mutable, so init it here rather than
            # using "Timing()" as a default value.
            self.payment_timing = Timing(high_precision=self.high_precision)
        else:
            self.payment_timing = Timing(payment_timing,
                                         high_precision=self.high_precision)

        # For attributes wrapped by ordinary properties, create hidden
        # attributes and assign to them using the properties:
        self._birth_date = None
        self._retirement_date = None
        self._raise_rate_callable = None
        self._spouse = None
        self._tax_treatment = None
        self._contribution_room = {}
        self._contribution_groups = {}
        self.birth_date = birth_date
        self.retirement_date = retirement_date
        self.raise_rate_callable = raise_rate
        self.spouse = spouse
        self.tax_treatment = tax_treatment

        # Now provide initial-year values for recorded properties:
        self.gross_income = gross_income  # Money value
        # NOTE: Be sure to set up tax_treatment before calling tax_withheld
        self.net_income = self.gross_income - self.tax_withheld

        # Finally, build an empty set for accounts to add themselves to
        # and a `data` dict for accounts to write unstructed data to.
        self.accounts = set()
        self.data = {}
Exemplo n.º 6
0
 def test_init_dict_money(self):
     """ Init `Timing` with a dict of `Money` values. """
     # It doesn't matter what this dict is; if we wrap its values as
     # `Money` objects, it should parse to the same result:
     timing_dict = {0: 0, 0.5: -2, 1: 1}
     money_dict = {key: Money(value) for key, value in timing_dict.items()}
     # Build timings based on the (otherwise-identical) Money and
     # non-Money inputs and confirm that the results are the same:
     timing1 = Timing(timing_dict)
     timing2 = Timing(money_dict)
     self.assertEqual(timing1, timing2)
Exemplo n.º 7
0
    def test_init_dict_mixed_neg_2(self):
        """ Init `Timing` with a net-negative dict of mixed values.

        This tests basically the same behaviour as
        `test_init_dict_mixed_neg`, but was written much earlier
        (as `test_init_dict_accum`) and helped to track down some
        erroneous behaviour. It's retained for historical reasons.
        """
        available = {0: 1000, 0.25: -11000, 0.5: 1000, 0.75: -11000}
        timing = Timing(available)
        target = Timing({0.25: 10000, 0.75: 10000})
        self.assertEqual(timing, target)
Exemplo n.º 8
0
    def test_init_dict_mixed_zero(self):
        """ Init `Timing` with a net-zero dict of mixed values.

        "Mixed" in this context means it has both positive and negative
        values. The sum of those values is zero in this test.
        """
        # Large outflow followed by equally large inflow:
        timing_dict = {0: 0, 0.5: -2, 1: 2}
        timing = Timing(timing_dict)
        # This should result in either an empty time-series or one with
        # all-zero values.
        self.assertTrue(all(value == 0 for value in timing.values()))
Exemplo n.º 9
0
    def test_init_dict_mixed_pos(self):
        """ Init `Timing` with a net-positive dict of mixed values.

        "Mixed" in this context means it has both positive and negative
        values. The sum of those values is positive in this test.
        """
        # Large inflow followed by small outflow (for small net inflow):
        timing_dict = {0: 0, 0.5: 2, 1: -1}
        timing = Timing(timing_dict)
        # There should be only one key with non-zero weight:
        self.assertNotEqual(timing[0.5], 0)
        # All other keys must have zero weight, if they exist:
        self.assertTrue(
            all(value == 0 for key, value in timing.items() if key != 0.5))
Exemplo n.º 10
0
 def test_init_dict_pos(self):
     """ Init `Timing` with a dict of all-positive values. """
     # Technically we use non-negative values:
     timing_dict = {0: 0, 0.5: 1, 1: 1}
     # Should convert directly to `Timing` (the 0 value is optional):
     timing = Timing(timing_dict)
     total_weight = sum(timing.values())
     for key, value in timing_dict.items():
         if value != 0:
             # Each non-0 value should be present...
             self.assertIn(key, timing)
             # ... and both non-0 values should be equally weighted.
             self.assertEqual(timing[key], total_weight / 2)
         elif key in timing:
             # The zero value, if it exists, should remain 0:
             self.assertEqual(timing[key], 0)
Exemplo n.º 11
0
 def __init__(self,
              initial_year,
              default_timing=None,
              *,
              high_precision=None):
     """ Initializes an instance of SubForecast. """
     # Invoke Ledger's __init__ or pay the price!
     # NOTE Issue #53 removes this requirement
     super().__init__(initial_year, high_precision=high_precision)
     # Use default Timing (i.e. lump sum contributions at the
     # midpoint of the year) if none is explicitly provided:
     if default_timing is None:
         self.default_timing = Timing(high_precision=high_precision)
     else:
         self.default_timing = default_timing
     # We store transactions to/from each account so that we can
     # unwind or inspect transactions caused by this subforecast
     # later. So we store it as `{account: {when: value}}`.
     # Since `account` can be a dict (which is non-hashable),
     # we use a custom subclass of defaultdict that allows
     # non-hashable keys.
     self._transactions = TransactionDict(
         lambda: defaultdict(lambda: 0))  # Money value
     # If the subforecast is called more than once, we
     # may want to do some unwinding. Use this to track
     # whether this subforecast has been called before:
     self._call_invoked = False
     self.total_available = 0  # Money value
Exemplo n.º 12
0
    def __init__(self,
                 owner=None,
                 balance=0,
                 rate=0,
                 nper=1,
                 default_timing=None,
                 inputs=None,
                 initial_year=None):
        """ Constructor for `Account`.

        This constructor receives only values for the first year.

        Args:
            owner (Person): The owner of the account. Optional.
            balance (Money): The balance for the first year
            rate (Decimal, callable): An object that gives the rate for
                each year, either as a constant value (e.g. a Decimal)
                or as a callable object with a signature of the form
                `rate(year) -> Decimal`.

                If this callable object relies on `Scenario` or other
                objects defined in the `forecaster` package, recommend
                passing an object that stores these objects explicitly
                as attributes (as opposed to a method/function where
                these objects are stored in the context), otherwise
                `Forecaster`'s object-substitution logic will not work.
            nper (int): The number of compounding periods per year.
            default_timing (Timing): The usual schedule for transactions
                to/from this account, used by various methods when no
                `timing` arg is expressly provided. Optional.
            initial_year (int): The first year (e.g. 2000)
        """
        # Use the explicitly-provided initial year if available,
        # otherwise default to the owner's initial year:
        if initial_year is None:
            if not hasattr(owner, 'initial_year'):
                raise TypeError(
                    'Account: owner must have initial_year attribute.')
            else:
                initial_year = owner.initial_year
        super().__init__(initial_year=initial_year, inputs=inputs)

        # Set hidden attributes to support properties that need them to
        # be set in advance:
        self._owner = None
        self._transactions = defaultdict(lambda: Money(0))
        self._rate_callable = None
        self._default_timing = None

        # Set the various property values based on inputs:
        self.owner = owner
        self.balance = Money(balance)
        self.rate_callable = rate
        self.nper = frequency_conv(nper)
        if default_timing is None:
            self.default_timing = Timing()
        else:
            self.default_timing = default_timing
Exemplo n.º 13
0
    def test_init_dict_neg(self):
        """ Init `Timing` with a dict of all-negative values. """
        # This returns the same result as `test_init_dict_pos`;
        # the inputs have their sign flipped, but output is the same.

        # Technically we use non-positive values:
        timing_dict = {0: 0, 0.5: -1, 1: -1}
        # Should convert directly to `Timing`, except that all values
        # have their sign flipped (the 0 value is optional):
        timing = Timing(timing_dict)
        # Ensure that `total_weight` is positive; the later assertEqual
        # tests will thus also check that all values are non-negative:
        total_weight = abs(sum(timing.values()))
        for key, value in timing_dict.items():
            if value != 0:
                # Each non-0 value should be present...
                self.assertIn(key, timing)
                # ... and both non-0 values should be equally weighted.
                self.assertEqual(timing[key], total_weight / 2)
            elif key in timing:
                # The zero value, if it exists, should remain 0:
                self.assertEqual(timing[key], 0)
Exemplo n.º 14
0
    def test_init_dict_mixed_neg(self):
        """ Init `Timing` with a net-negative dict of mixed values.

        "Mixed" in this context means it has both positive and negative
        values. The sum of those values is negative in this test.
        """
        # Large outflow followed by small inflow and then another large
        # outflow:
        timing_dict = {0: 0, 0.5: -2, 0.75: 1, 1: -2}
        timing = Timing(timing_dict)
        # This should result in a time-series of {0.5: 2, 1: 1} to
        # balance the net flows at each outflow.
        # NOTE: The behaviour is different than is provided for inflows!
        self.assertNotEqual(timing[0.5], 0)
        self.assertNotEqual(timing[1], 0)
        # Key 0.5 should have 2x the weight of key 1:
        self.assertEqual(timing[0.5], timing[1] * 2)
        # All other keys must have zero weight, if they exist:
        self.assertTrue(
            all(
                value == 0 for key, value in timing.items()
                if key not in (0.5, 1)))
Exemplo n.º 15
0
    def _carryover_tax(self, tax_adjustment_previous):
        """ Add tax refunds/payments arising from last year's taxes. """
        # Nothing to carry over is there's no known tax adjustment:
        if tax_adjustment_previous is None:
            return

        # Determine the time series of transactions as appropriate:
        if tax_adjustment_previous > 0:  # refund
            if hasattr(self.tax_forecast, "tax_refund_timing"):
                timing = self.tax_forecast.tax_refund_timing
            else:
                timing = Timing(0, high_precision=self.high_precision)
        elif tax_adjustment_previous < 0:  # payment owing
            if hasattr(self.tax_forecast, "tax_payment_timing"):
                timing = self.tax_forecast.tax_payment_timing
            else:
                timing = Timing(0, high_precision=self.high_precision)
        else:  # no adjustment
            return  # No need to proceed on to add_transactions
        # Add the time-series of transactions to `available`:
        transactions = timing.time_series(tax_adjustment_previous)
        add_transactions(self.available, transactions)
Exemplo n.º 16
0
    def transactions_to_balance(self,
                                balance,
                                timing=None,
                                max_total=None,
                                min_total=None,
                                transactions=None):
        """ The amounts to add/withdraw at `timing` to get `balance`.

        The return value satisfies two criteria:

        * If each `{when: value}` pair is added as a transaction
          then `self.balance_at_time('end')` will return `balance`,
          subject to precision-based error.
        * Each `value` is proportionate to the corresponding
          input `weight` for the given timing.

        Note that this method does not guarantee that the Account will
        not go into negative balance mid-year if the output is used to
        apply transactions to the Account.

        This method is transaction-aware in the sense that the resulting
        transactions are additional to any transactions already added to
        the account.

        Arguments:
            timing (Timing): A mapping of `{when: weight}` pairs.
            balance (Money): The balance of the Account would
                have after applying the outflows.
            max_total (Money): If provided, the resulting transactions
                will not exceed this total value (even if this value is
                not sufficient to achieve `balance`)
            min_total (Money): If provided, the resulting transactions
                will be at least this total value (even if this value is
                larger than necessary to achieve `balance`)
            transactions (dict[Decimal, Money]): If provided, the result
                of this method will be determined as if the account
                also had these transactions recorded against it.

        Returns:
            dict[float, Money]: A mapping of `{when: value}` pairs where
                value indicates the amount that can be withdrawn at that
                time such that, by the end of the year, the Account's
                balance is `balance`.
        """
        # Determine how much the end-of-year balance would change under
        # these transactions. This accounts for any transactions already
        # applied to the account (via balance_at_time):
        ref_balance = self.balance_at_time('end', transactions=transactions)
        change = balance - ref_balance

        # Clean inputs:
        if timing is None or not timing:
            # Use default timing if none was explicitly provided.
            # NOTE: Consider whether we should instead use whatever
            # timing is naturally suggested by the timings of the
            # current account transactions.
            # For example, if an account receives $50 at when=0.5 and a
            # $25 withdrawal at when=1, then the maximum outflow would
            # occur at when=0.5 (and would have a value of $25, assuming
            # no growth).
            # The benefit here is that we could provide a schedule of
            # transactions which *does* guarantee that the account will
            # not go into negative balance at any time!
            # A side-effect is that we might move `default_timing` logic
            # back to the classes that need it (i.e. Debt, Person).
            timing = self.default_timing
        elif not isinstance(timing, Timing):
            # Convert timing from str or dict if appropriate:
            timing = Timing(timing)
        # Weights aren't guaranteed to sum to 1, so normalize them so
        # they do. This will help later.
        # (Also convert to Decimal to avoid Decimal/float mismatch.)
        total_weight = Decimal(sum(timing.values()))
        # If calling code has passed in a timing object with 0 net
        # weights (i.e. values) but non-empty timings (i.e. keys),
        # assume uniform weights at those timings:
        if total_weight == 0:
            timing = Timing({when: 1 for when in timing.keys()})
            total_weight = len(timing)
        # Normalize so that we can conveniently multiply weights by
        # `total` to get that amount spread across all timings:
        # (Exclude zero-valued weights for two reasons: we don't need
        # them and they lead to errors when multipling by infinity)
        normalized_timing = {
            when: Decimal(weight) / total_weight
            for when, weight in timing.items() if weight != 0
        }

        # We want the value at each timing to be proportional to its
        # weight. This calls for math.
        # Consider this derivation, where:
        # * A is the accumulation function using the current rate/nper
        # * Each {timing: value} pair in output is abbreviated t_i: v_i
        # * The sum total of all transaction values is denoted s

        # change = A(1-t_1)*v_1 + A(1-t_2)*v_2 + ... + A(1-t_n)*v_n
        #   (This is just the sum of future values of the transactions)
        # v_j = sum(v_1 ... v_n) * w_j = s * w_j for all j in [1 .. n]
        #   (This is the constraint that each v_j is proportional to its
        #   weight w_j. Note that it assumes w_j is normalized!)
        # change = A(1-t_1)*s*w_1 + ... + A(1-t_n)*s*w_n
        #   (Obtain this by simple substitution of v_j = s * w_j)
        # s = change / (A(1-t_1)*w_1 + ... + A(1-t_n)*w_n)
        #   (We can use this to determine v_j)

        # Since s (the total) is the same for all values, find it first:
        weighted_accum = Decimal(0)
        for timing, weight in normalized_timing.items():
            weighted_accum += weight * accumulation_function(
                t=1 - timing, rate=self.rate, nper=self.nper)
        total = change / weighted_accum

        # Limit total transaction value based on args:
        if max_total is not None:
            total = min(total, max_total)
        if min_total is not None:
            total = max(total, min_total)

        # Now find value (v_j) for each timing (t_j) by applying
        # normalized weights to the total:
        result_transactions = {
            timing: total * weight
            for timing, weight in normalized_timing.items()
        }
        return result_transactions
Exemplo n.º 17
0
 def default_timing(self):
     """ Deletes default_timing. """
     # Return default_timing to its default value:
     self._default_timing = Timing()
Exemplo n.º 18
0
 def default_timing(self, val):
     """ Sets default_timing. """
     # Cast to `Timing` type:
     if not isinstance(val, Timing):
         val = Timing(val)
     self._default_timing = val
Exemplo n.º 19
0
 def test_init_when_freq(self):
     """ Init `Timing` with a single parameter, `when`. """
     timing = Timing(0.5)
     self.assertEqual(set(timing.keys()), {0.5})
Exemplo n.º 20
0
 def default_timing(self):
     """ Deletes default_timing. """
     # Return default_timing to its default value:
     self._default_timing = Timing(high_precision=self.high_precision)
Exemplo n.º 21
0
    def __init__(self,
                 owner=None,
                 balance=None,
                 rate=None,
                 nper=None,
                 default_timing=None,
                 inputs=None,
                 initial_year=None,
                 *,
                 high_precision=None,
                 **kwargs):
        """ Constructor for `Account`.

        This constructor receives only values for the first year.

        Args:
            owner (Person): The owner of the account. Optional.
            balance (float): The balance for the first year. Optional,
                defaults to 0.
            rate (float, callable): An object that gives the rate for
                each year, either as a constant value (e.g. 1.0 implies
                a 100% interest rate) or as a callable object with a
                signature of the form `rate(year) -> Decimal`.

                If this callable object relies on `Scenario` or other
                objects defined in the `forecaster` package, recommend
                passing an object that stores these objects explicitly
                as attributes (as opposed to a method/function where
                these objects are stored in the context), otherwise
                `Forecaster`'s object-substitution logic will not work.

                Optional. Defaults to 0 (i.e. no growth/interest/etc.)
            nper (int): The number of compounding periods per year.
                Optional. Defaults to 1 (i.e. compounds once annually).
            default_timing (Timing): The usual schedule for transactions
                to/from this account, used by various methods when no
                `timing` arg is expressly provided. Optional.
            initial_year (int): The first year (e.g. 2000)
            high_precision (Callable[[float], T]): Takes a single
                `float` argument and converts it to high-precision
                numeric type `T`, such as Decimal.
        """
        # Use the explicitly-provided initial year if available,
        # otherwise default to the owner's initial year:
        if initial_year is None:
            if not hasattr(owner, 'initial_year'):
                raise TypeError(
                    'Account: owner must have initial_year attribute.')
            else:
                initial_year = owner.initial_year
        # Defer Ledger's init until we can pass initial_year:
        super().__init__(initial_year=initial_year,
                         inputs=inputs,
                         high_precision=high_precision,
                         **kwargs)

        # For numerical optional inputs, ensure an appropriately-typed
        # default value is used. (Do this after __init__!)
        if balance is None:
            balance = self.precision_convert(0)
        if rate is None:
            rate = self.precision_convert(0)
        if nper is None:
            nper = self.precision_convert(1)

        # Set hidden attributes to support properties that need them to
        # be set in advance:
        self._owner = None
        self._transactions = defaultdict(
            lambda: self.precision_convert(0))  # Money value
        self._rate_callable = None
        self._default_timing = None
        self._nper = None

        # Set the various property values based on inputs:
        self.owner = owner
        self.balance = balance  # Money value
        self.rate_callable = rate
        self.nper = frequency_conv(nper)
        if default_timing is None:
            self.default_timing = Timing(high_precision=high_precision)
        else:
            self.default_timing = default_timing
Exemplo n.º 22
0
 def test_init_str_freq(self):
     """ Init `Timing` with a single str parameter, `frequency`. """
     # There are 12 occurances with a monthly frequency:
     timing = Timing(frequency="M")
     self.assertEqual(len(timing), 12)
Exemplo n.º 23
0
 def refund_timing(self, val):
     """ Sets `refund_timing`. """
     self._refund_timing = Timing(val)
Exemplo n.º 24
0
 def payment_timing(self, val):
     """ Sets `payment_timing`. """
     self._payment_timing = Timing(val)
Exemplo n.º 25
0
 def test_init_str_when(self):
     """ Init `Timing` with a single str parameter, `when`. """
     # 'start' should convert to 0:
     timing = Timing(when='start')
     self.assertEqual(set(timing.keys()), {0})
Exemplo n.º 26
0
    def add_transaction(self,
                        value,
                        timing=None,
                        from_account=None,
                        to_account=None,
                        strict_timing=False):
        """ Records a transaction at a time that balances the books.

        This method will always add the transaction at or after `when`
        (or at or after the implied timing provided by `frequency`).
        It tries to find a time where adding the transaction would
        avoid putting `from_account` into a negative balance (not
        only at the time of the transaction but at any subsequent
        time).

        In particular, it tries to find the _earliest_ workable
        time at or after `when`. Thus, `when` is used if it meets
        these constraints. `when` is also used if no such
        time can be found.

        The transaction is actually two transactions: an outflow
        from `from_account` and an inflow to `to_account`. (This is
        reversed if `value` is negative.) If an account are omitted,
        the method treats the money as coming from (and/or going to)
        an infinite pool of money outside of the model.

        The `*_account` parameters are not necessarily `Account`
        objects. `dict[float, float]` (a dict of timings mapped to
        transaction values) or anything with similar semantics will
        also work.

        Args:
            value (float): The value of the transaction.
                Positive for inflows, negative for outflows.
            timing (Timing, dict[float, float], float, str):
                This is either a Timing object or a value that can be
                converted to a Timing object (e.g. a dict of
                {timing: weight} pairs, a `when` value as a float or
                string, etc.). Optional; defaults to `default_timing`.
            from_account (Account, dict[Decimal, float]): An account
                (or dict of transactions) from which the transaction
                originates. Optional.
            from_account (Account, dict[Decimal, float]): An account
                (or dict of transactions) to which the transaction
                is being sent. Optional.
            strict_timing (bool): If False, transactions may be added
                later than `when` if this avoids putting accounts in
                a negative balance. If True, `when` is always used.
        """
        # NOTE: If `value` expects a Money value input (like PyMoney)
        # that may need to be converted from a scalar, do that here
        if timing is None:
            # Rather than build a new Timing object here, we'll use
            # the default timing for this SubForecast.
            timing = self.default_timing
        elif not isinstance(timing, Timing):
            # This allows users to pass `when` inputs and have them
            # parse correctly, since `Timing(when)` converts to
            # {when: 1}, i.e. a lump-sum occuring at `when`.
            timing = Timing(timing, high_precision=self.high_precision)

        # For convenience, ensure that we're withdrawing from
        # from_account and depositing to to_account:
        if value < 0:
            from_account, to_account = to_account, from_account
            value = -value

        # (Normalize weights just in case client code was naughty and
        # didn't do that for us...)
        total_weight = sum(timing.values())
        # Add a transaction at each timing, with a transaction value
        # proportionate to the (normalized) weight for its timing:
        for when, weight in timing.items():
            weighted_value = value * (weight / total_weight)
            self._add_transaction(value=weighted_value,
                                  when=when,
                                  from_account=from_account,
                                  to_account=to_account,
                                  strict_timing=strict_timing)