Esempio n. 1
0
 def test_constructors(self):
     Position(Lot('USD', None, None), D('123.45'))
     Position(Lot('USD', A('74.00 CAD'), None), D('123.45'))
     Position(Lot('USD', A('74.00 CAD'), date(2013, 2, 3)), D('123.45'))
     with self.assertRaises(Exception):
         Position(None, D('123.45'))
     Position(Lot('USD', None, None), None)
Esempio n. 2
0
    def test_from_amounts(self):
        pos = from_amounts(A('10.00 USD'))
        self.assertEqual(Position(Lot("USD", None, None), D('10')), pos)

        pos = from_amounts(A('10 HOOL'), A('510.00 USD'))
        self.assertEqual(Position(Lot("HOOL", A('510 USD'), None), D('10')),
                         pos)
Esempio n. 3
0
    def test_compare_zero_to_none(self):
        pos1 = Position(Lot("CAD", None, None), ZERO)
        pos_none = None
        self.assertEqual(pos1, pos_none)
        self.assertEqual(pos_none, pos1)

        pos2 = Position(Lot("USD", None, None), ZERO)
        self.assertNotEqual(pos1, pos2)
Esempio n. 4
0
 def test_get_position(self):
     inv = Inventory(self.POSITIONS_ALL_KINDS)
     self.assertEqual(
         position.from_string('40.50 USD'),
         inv.get_position(Lot('USD', None, None)))
     self.assertEqual(
         position.from_string('40.50 USD {1.10 CAD}'),
         inv.get_position(Lot('USD', A('1.10 CAD'), None)))
     self.assertEqual(
         position.from_string('40.50 USD {1.10 CAD, 2012-01-01}'),
         inv.get_position(Lot('USD', A('1.10 CAD'), date(2012, 1, 1))))
Esempio n. 5
0
    def test_eq_and_sortkey__bycost(self):
        pos1 = Position(Lot("USD", None, None), D('1'))
        pos2 = Position(Lot("USD", A('10 USD'), None), D('1'))
        pos3 = Position(Lot("USD", A('11 USD'), None), D('1'))
        pos4 = Position(Lot("USD", A('12 USD'), None), D('1'))

        positions = [pos3, pos2, pos1, pos4]
        self.assertEqual([pos1, pos2, pos3, pos4], sorted(positions))

        for _ in range(64):
            random.shuffle(positions)
            self.assertEqual([pos1, pos2, pos3, pos4], sorted(positions))
Esempio n. 6
0
def create_simple_posting_with_cost(entry, account,
                                    number, currency,
                                    cost_number, cost_currency):
    """Create a simple posting on the entry, with just a number and currency (no cost).

    Args:
      entry: The entry instance to add the posting to.
      account: A string, the account to use on the posting.
      number: A Decimal number or string to use in the posting's Amount.
      currency: A string, the currency for the Amount.
      cost_number: A Decimal number or string to use for the posting's cost Amount.
      cost_currency: a string, the currency for the cost Amount.
    Returns:
      An instance of Posting, and as a side-effect the entry has had its list of
      postings modified with the new Posting instance.
    """
    if isinstance(account, str):
        pass
    if not isinstance(number, Decimal):
        number = D(number)
    if cost_number and not isinstance(cost_number, Decimal):
        cost_number = D(cost_number)
    cost = Amount(cost_number, cost_currency)
    position = Position(Lot(currency, cost, None), number)
    posting = Posting(account, position, None, None, None)
    if entry is not None:
        entry.postings.append(posting)
    return posting
Esempio n. 7
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')))
Esempio n. 8
0
    def test_eq_and_sortkey(self):
        pos1 = Position(Lot("USD", None, None), D('200'))
        pos2 = Position(Lot("USD", None, None), D('201'))
        pos3 = Position(Lot("CAD", None, None), D('100'))
        pos4 = Position(Lot("CAD", None, None), D('101'))
        pos5 = Position(Lot("ZZZ", None, None), D('50'))
        positions = [pos5, pos4, pos3, pos2, pos1]
        positions.sort()

        self.assertTrue(pos1 < pos2)
        self.assertTrue(pos2 < pos3)
        self.assertTrue(pos3 < pos4)
        self.assertTrue(pos4 < pos5)

        self.assertTrue(positions[0] is pos1)
        self.assertTrue(positions[1] is pos2)
        self.assertTrue(positions[2] is pos3)
        self.assertTrue(positions[3] is pos4)
        self.assertTrue(positions[4] is pos5)
Esempio n. 9
0
    def add_amount(self, amount, cost=None, lot_date=None):
        """Add to this inventory using amount, cost and date. This adds with strict lot
        matching, that is, no partial matches are done on the arguments to the
        keys of the inventory.

        Args:
          amount: An Amount instance to add. The amount's currency is used as a
            key on the inventory.
          cost: An instance of Amount or None, as a key to the inventory.
          lot_date: An instance of datetime.date or None, the lot-date to use in
            the key to the inventory.
        Returns:
          A pair of (position, booking) where 'position' is the position that
          that was modified, and where 'booking' is a Booking enum that hints at
          how the lot was booked to this inventory.
        """
        assert isinstance(amount, Amount)
        assert cost is None or isinstance(cost, Amount), repr(cost)
        assert lot_date is None or isinstance(lot_date, date)
        lot = Lot(amount.currency, cost, lot_date)
        return self._add(amount.number, lot)
Esempio n. 10
0
 def test_from_string__with_everything(self):
     pos = from_string(
         '20 HOOL {*, 532.43 # 20.00 USD, "e4dc1a361022", 2014-06-15}')
     cost = A('533.43 USD')
     lot_date = datetime.date(2014, 6, 15)
     self.assertEqual(Position(Lot("HOOL", cost, lot_date), D('20')), pos)
Esempio n. 11
0
 def test_lot_currency_pair(self):
     self.assertEqual(("USD", None),
                      lot_currency_pair(Lot("USD", None, None)))
     self.assertEqual(("AAPL", "USD"),
                      lot_currency_pair(Lot("AAPL", A('43.23 USD'), None)))
Esempio n. 12
0
 def test_from_string__with_compound_cost(self):
     pos = from_string('1.1 HOOL {500.00 # 11.00 USD}')
     self.assertEqual(
         Position(Lot("HOOL", A('510.00 USD'), None), D('1.1')), pos)
Esempio n. 13
0
 def test_from_string__with_merge_cost_spec(self):
     pos = from_string('1.1 HOOL {*}')
     self.assertEqual(Position(Lot("HOOL", None, None), D('1.1')), pos)
Esempio n. 14
0
 def test_from_string__with_cost_and_date(self):
     pos = from_string('2.2 HOOL {532.43 USD, 2014-06-15}')
     cost = A('532.43 USD')
     lot_date = datetime.date(2014, 6, 15)
     self.assertEqual(Position(Lot("HOOL", cost, lot_date), D('2.2')), pos)
Esempio n. 15
0
 def test_from_string__with_label(self):
     pos = from_string('2.2 HOOL {"78c3f7f1315b"}')
     self.assertEqual(Position(Lot("HOOL", None, None), D('2.2')), pos)
Esempio n. 16
0
 def test_copy(self):
     # Ensure that the lot instances are shared.
     pos1 = Position(Lot("USD", None, None), D('200'))
     pos2 = copy.copy(pos1)
     self.assertTrue(pos1.lot is pos2.lot)
Esempio n. 17
0
 def test_add(self):
     pos = Position(Lot("USD", A('10 AUD'), None), D('28372'))
     pos.add(D('337'))
     self.assertEqual(A('28709 USD'), pos.get_units())
     self.assertEqual(A('287090 AUD'), pos.get_cost())
Esempio n. 18
0
 def test_negative(self):
     pos = Position(Lot("USD", A('10 AUD'), None), D('28372'))
     negpos = pos.get_negative()
     self.assertEqual(A('-28372 USD'), negpos.get_units())
     self.assertEqual(A('-283720 AUD'), negpos.get_cost())
Esempio n. 19
0
 def test_neg(self):
     pos = Position(Lot("CAD", None, None), D('7'))
     npos = -pos
     self.assertEqual(D('-7'), npos.number)
     self.assertEqual(pos.lot, npos.lot)
Esempio n. 20
0
 def test_is_negative_at_cost(self):
     pos1 = Position(Lot("USD", A('10 AUD'), None), D('1'))
     pos2 = Position(Lot("USD", A('10 AUD'), None), D('-1'))
     self.assertFalse(pos1.is_negative_at_cost())
     self.assertTrue(pos2.is_negative_at_cost())
Esempio n. 21
0
 def test_from_string__simple(self):
     pos = from_string('10 USD')
     self.assertEqual(Position(Lot("USD", None, None), D('10')), pos)
Esempio n. 22
0
 def test_from_string__with_spaces(self):
     pos = from_string(' - 111.2934  CAD ')
     self.assertEqual(Position(Lot("CAD", None, None), D('-111.2934')), pos)
Esempio n. 23
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)
Esempio n. 24
0
 def test_from_string__with_cost(self):
     pos = from_string('2.2 HOOL {532.43 USD}')
     cost = A('532.43 USD')
     self.assertEqual(Position(Lot("HOOL", cost, None), D('2.2')), pos)
Esempio n. 25
0
    def test_from_string(self):
        inv = inventory.from_string('')
        self.assertEqual(Inventory(), inv)

        inv = inventory.from_string('10 USD')
        self.assertEqual(
            Inventory([Position(Lot("USD", None, None), D('10'))]),
            inv)

        inv = inventory.from_string(' 10.00  USD ')
        self.assertEqual(
            Inventory([Position(Lot("USD", None, None), D('10'))]),
            inv)

        inv = inventory.from_string('1 USD, 2 CAD')
        self.assertEqual(
            Inventory([Position(Lot("USD", None, None), D('1')),
                       Position(Lot("CAD", None, None), D('2'))]),
            inv)

        inv = inventory.from_string('2.2 HOOL {532.43 USD}, 3.413 EUR')
        self.assertEqual(
            Inventory([Position(Lot("HOOL", A('532.43 USD'), None),
                                D('2.2')),
                       Position(Lot("EUR", None, None), D('3.413'))]),
            inv)

        inv = inventory.from_string(
            '2.2 HOOL {532.43 USD}, 2.3 HOOL {564.00 USD, 2015-07-14}, 3.413 EUR')
        self.assertEqual(
            Inventory([Position(Lot("HOOL", A('532.43 USD'), None),
                                D('2.2')),
                       Position(Lot("HOOL", A('564.00 USD'), datetime.date(2015, 7, 14)),
                                D('2.3')),
                       Position(Lot("EUR", None, None),
                                D('3.413'))]),
            inv)

        inv = inventory.from_string(
            '1.1 HOOL {500.00 # 11.00 USD}, 100 CAD')
        self.assertEqual(
            Inventory([Position(Lot("HOOL", A('510.00 USD'), None),
                                D('1.1')),
                       Position(Lot("CAD", None, None),
                                D('100'))]),
            inv)
Esempio n. 26
0
def convert_lot_specs_to_lots(entries, unused_options_map):
    """For all the entries, convert the posting's position's LotSpec to Lot instances.

    This essentially replicates the way the old parser used to work, but
    allowing positions to have the fuzzy lot specifications instead of the
    resolved ones. We used to simply compute the costs locally, and this gets
    rid of the LotSpec to produce the Lot without fuzzy matching. This is only
    there for the sake of transition to the new matching logic.

    Args:
      entries: A list of incomplte directives as per the parser.
      options_map: An options dict from the parser.
    Returns:
      A list of entries whose postings's positions have been converted to Lot
      instances but that may still be incomplete.
    """
    new_entries = []
    errors = []
    for entry in entries:
        if not isinstance(entry, Transaction):
            new_entries.append(entry)
            continue

        new_postings = []
        for posting in entry.postings:
            pos = posting.position
            if pos is not None:
                currency, compound_cost, lot_date, label, merge = pos.lot

                # Compute the cost.
                if compound_cost is not None:
                    if compound_cost.number_total is not None:
                        # Compute the per-unit cost if there is some total cost
                        # component involved.
                        units = pos.number
                        cost_total = compound_cost.number_total
                        if compound_cost.number_per is not None:
                            cost_total += compound_cost.number_per * units
                        unit_cost = cost_total / abs(units)
                    else:
                        unit_cost = compound_cost.number_per
                    cost = Amount(unit_cost, compound_cost.currency)
                else:
                    cost = None

                # If there is a cost, we don't allow either a cost value of
                # zero, nor a zero number of units. Note that we allow a price
                # of zero as the only special case (for conversion entries), but
                # never for costs.
                if cost is not None:
                    if pos.number == ZERO:
                        errors.append(
                            BookingError(entry.meta,
                                         'Amount is zero: "{}"'.format(pos), None))

                    if cost.number is not None and cost.number < ZERO:
                        errors.append(
                            BookingError(entry.meta,
                                         'Cost is negative: "{}"'.format(cost), None))

                lot = Lot(currency, cost, lot_date)
                posting = posting._replace(position=Position(lot, pos.number))

            new_postings.append(posting)
        new_entries.append(entry._replace(postings=new_postings))
    return new_entries, errors