예제 #1
0
    def test_quantities(self):
        pos = Position(Lot("USD", None, None), D('10'))
        self.assertEqual(A('10 USD'), pos.get_units())
        self.assertEqual(A('10 USD'), pos.get_cost())
        self.assertEqual(A('10 USD'), pos.get_weight())
        self.assertEqual(A('16 AUD'), pos.get_weight(A('1.6 AUD')))

        pos = Position(Lot("USD", A('1.5 AUD'), None), D('10'))
        self.assertEqual(A('10 USD'), pos.get_units())
        self.assertEqual(A('15 AUD'), pos.get_cost())
        self.assertEqual(A('15 AUD'), pos.get_weight())
        self.assertEqual(A('15 AUD'), pos.get_weight(A('1.6 AUD')))

        cost_pos = pos.cost()
        self.assertEqual(A('15 AUD'), cost_pos.get_units())
        self.assertEqual(A('15 AUD'), cost_pos.get_cost())
        self.assertEqual(A('15 AUD'), cost_pos.get_weight())
        with self.assertRaises(AssertionError):
            self.assertEqual(A('15 AUD'), cost_pos.get_weight(A('1.6 AUD')))
예제 #2
0
def get_incomplete_postings(entry, options_map):
    """Balance an entry with auto-postings and return an updated list of completed postings.

    Returns a new list of balanced postings, with the incomplete postings
    replaced with completed ones. This is probably the only place where there
    is a bit of non-trivial logic in this entire project (and the rewrite was
    to make sure it was *that* simple.)

    Note that inferred postings are tagged via metatada with an '__automatic__'
    field added to them with a true boolean value.

    Note: The 'postings' parameter may be modified or destroyed for performance
    reasons; don't reuse it.

    Args:
      entry: An instance of a valid directive.
      options_map: A dict of options, as produced by the parser.
    Returns:
      A tuple of:
        postings: a list of new postings to replace the entry's unbalanced
          postings.
        inserted: A boolean set to true if we've inserted new postings.
        errors: A list of balance errors generated during the balancing process.
        residual: A Inventory instance, the residual amounts after balancing
          the postings.
        tolerances: The tolerances inferred in the process, using the postings
          provided.
    """
    # Make a copy of the original list of postings.
    postings = list(entry.postings)

    # Errors during balancing.
    balance_errors = []

    # The list of postings without and with an explicit position.
    auto_postings_indices = []

    # Currencies seen in complete postings.
    currencies = set()

    # An inventory to accumulate the residual balance.
    residual = Inventory()

    # A dict of values for default tolerances.
    if options_map['use_legacy_fixed_tolerances']:
        # This is supported only to support an easy transition for users.
        # Users should be able to revert to this easily.
        tolerances = {}
        default_tolerances = LEGACY_DEFAULT_TOLERANCES
    else:
        tolerances = infer_tolerances(postings, options_map)
        default_tolerances = options_map['default_tolerance']

    # Process all the postings.
    has_nonzero_amount = False
    has_regular_postings = False
    for i, posting in enumerate(postings):
        position = posting.position

        if position is None:
            # This posting will have to get auto-completed.
            auto_postings_indices.append(i)
        else:
            currencies.add(position.lot.currency)

            # Compute the amount to balance and update the inventory.
            weight = get_posting_weight(posting)
            residual.add_amount(weight)

            has_regular_postings = True
            if weight:
                has_nonzero_amount = True

    # If there are auto-postings, fill them in.
    has_inserted = False
    if auto_postings_indices:

        # If there are too many such postings, we can't do anything, barf.
        if len(auto_postings_indices) > 1:
            balance_errors.append(
                BalanceError(entry.meta,
                             "Too many auto-postings; cannot fill in", entry))
            # Delete the redundant auto-postings.
            for index in sorted(auto_postings_indices[1:], reverse=1):
                del postings[index]

        index = auto_postings_indices[0]
        old_posting = postings[index]
        assert old_posting.price is None

        residual_positions = residual.get_positions()

        # If there are no residual positions, we want to still insert a posting
        # but with a zero position for each currency, so that the posting shows
        # up anyhow. We insert one such posting for each currency seen in the
        # complete postings. Note: if all the non-auto postings are zero, we
        # want to avoid sending a warning; the input text clearly implies the
        # author knows this would be useless.
        new_postings = []
        if not residual_positions and (
            (has_regular_postings and has_nonzero_amount)
                or not has_regular_postings):
            balance_errors.append(
                BalanceError(entry.meta,
                             "Useless auto-posting: {}".format(residual),
                             entry))
            for currency in currencies:
                position = Position(Lot(currency, None, None), ZERO)
                meta = copy.copy(old_posting.meta) if old_posting.meta else {}
                meta[AUTOMATIC_META] = True
                new_postings.append(
                    Posting(old_posting.account, position, None,
                            old_posting.flag, old_posting.meta))
                has_inserted = True
        else:
            # Convert all the residual positions in inventory into a posting for
            # each position.
            for position in residual_positions:
                position = -position

                # Applying rounding to the default tolerance, if there is one.
                tolerance = inventory.get_tolerance(tolerances,
                                                    default_tolerances,
                                                    position.lot.currency)
                if tolerance:
                    quantum = (tolerance * 2).normalize()

                    # If the tolerance is a neat number provided by the user,
                    # quantize the inferred numbers. See doc on quantize():
                    #
                    # Unlike other operations, if the length of the coefficient
                    # after the quantize operation would be greater than
                    # precision, then an InvalidOperation is signaled. This
                    # guarantees that, unless there is an error condition, the
                    # quantized exponent is always equal to that of the
                    # right-hand operand.
                    if len(quantum.as_tuple().digits) < MAX_TOLERANCE_DIGITS:
                        position.number = position.number.quantize(quantum)

                meta = copy.copy(old_posting.meta) if old_posting.meta else {}
                meta[AUTOMATIC_META] = True
                new_postings.append(
                    Posting(old_posting.account, position, None,
                            old_posting.flag, meta))
                has_inserted = True

                # Update the residuals inventory.
                weight = position.get_weight(None)
                residual.add_amount(weight)

        postings[index:index + 1] = new_postings

    else:
        # Checking for unbalancing transactions has been moved to the validation
        # stage, so although we already have the input transaction's residuals
        # conveniently precomputed here, we are postponing the check to allow
        # plugins to "fixup" unbalancing transactions. We want to allow users to
        # be able to input unbalancing transactions as long as the final
        # transactions objects that appear on the stream (after processing the
        # plugins) are balanced. See {9e6c14b51a59}.
        pass

    return (postings, has_inserted, balance_errors, residual, tolerances)