Example #1
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()))
Example #2
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)
Example #3
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)
Example #4
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
Example #5
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)