示例#1
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))
示例#2
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)))
示例#3
0
文件: base.py 项目: dxcv/forecaster
    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
示例#4
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)