Exemplo n.º 1
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.º 2
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.º 3
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})