def test_add_amount__withlots(self): # Testing the strict case where everything matches, with only a cost. inv = Inventory() inv.add_amount(A('50 HOOL'), Cost(D('700'), 'USD', None, None)) self.checkAmount(inv, '50', 'HOOL') inv.add_amount(A('-40 HOOL'), Cost(D('700'), 'USD', None, None)) self.checkAmount(inv, '10', 'HOOL') position_, _ = inv.add_amount(A('-12 HOOL'), Cost(D('700'), 'USD', None, None)) self.assertTrue(next(iter(inv)).is_negative_at_cost()) # Testing the strict case where everything matches, a cost and a lot-date. inv = Inventory() inv.add_amount(A('50 HOOL'), Cost(D('700'), 'USD', date(2000, 1, 1), None)) self.checkAmount(inv, '50', 'HOOL') inv.add_amount(A('-40 HOOL'), Cost(D('700'), 'USD', date(2000, 1, 1), None)) self.checkAmount(inv, '10', 'HOOL') position_, _ = inv.add_amount( A('-12 HOOL'), Cost(D('700'), 'USD', date(2000, 1, 1), None)) self.assertTrue(next(iter(inv)).is_negative_at_cost())
def test_add_amount__allow_negative(self): inv = Inventory() # Test adding positions of different types. position_, _ = inv.add_amount(A('-11 USD')) self.assertIsNone(position_) position_, _ = inv.add_amount(A('-11 USD'), Cost(D('1.10'), 'CAD', None, None)) self.assertIsNone(position_) position_, _ = inv.add_amount(A('-11 USD'), Cost(D('1.10'), 'CAD', date(2012, 1, 1), None)) self.assertIsNone(position_) # Check for reductions. invlist = list(inv) self.assertTrue(invlist[1].is_negative_at_cost()) self.assertTrue(invlist[2].is_negative_at_cost()) inv.add_amount(A('-11 USD'), Cost(D('1.10'), 'CAD', None, None)) inv.add_amount(A('-11 USD'), Cost(D('1.10'), 'CAD', date(2012, 1, 1), None)) self.assertEqual(3, len(inv)) # Test adding to a position that does exist. inv = I('10 USD, 10 USD {1.10 CAD}, 10 USD {1.10 CAD, 2012-01-01}') position_, _ = inv.add_amount(A('-11 USD')) self.assertEqual(position_, position.from_string('10 USD')) position_, _ = inv.add_amount(A('-11 USD'), Cost(D('1.10'), 'CAD', None, None)) self.assertEqual(position_, position.from_string('10 USD {1.10 CAD}')) position_, _ = inv.add_amount(A('-11 USD'), Cost(D('1.10'), 'CAD', date(2012, 1, 1), None)) self.assertEqual(position_, position.from_string('10 USD {1.10 CAD, 2012-01-01}'))
def test_op_neg(self): inv = Inventory() inv.add_amount(A('10 USD')) ninv = -inv self.checkAmount(ninv, '-10', 'USD') pinv = I('1.50 JPY, 1.51 USD, 1.52 CAD') ninv = I('-1.50 JPY, -1.51 USD, -1.52 CAD') self.assertEqual(pinv, -ninv)
def test_add_amount__multi_currency(self): inv = Inventory() inv.add_amount(A('100 USD')) inv.add_amount(A('100 CAD')) self.checkAmount(inv, '100', 'USD') self.checkAmount(inv, '100', 'CAD') inv.add_amount(A('25 USD')) self.checkAmount(inv, '125', 'USD') self.checkAmount(inv, '100', 'CAD')
def test_copy(self): inv = Inventory() inv.add_amount(A('100.00 USD')) self.checkAmount(inv, '100', 'USD') # Test copying. inv2 = copy.copy(inv) inv2.add_amount(A('50.00 USD')) self.checkAmount(inv2, '150', 'USD') # Check that the original object is not modified. self.checkAmount(inv, '100', 'USD')
def test_add_amount__booking(self): inv = Inventory() _, booking = inv.add_amount(A('100.00 USD')) self.assertEqual(Booking.CREATED, booking) _, booking = inv.add_amount(A('20.00 USD')) self.assertEqual(Booking.AUGMENTED, booking) _, booking = inv.add_amount(A('-20 USD')) self.assertEqual(Booking.REDUCED, booking) _, booking = inv.add_amount(A('-100 USD')) self.assertEqual(Booking.REDUCED, booking)
def test_ctor_empty_len(self): # Test regular constructor. inv = Inventory() self.assertTrue(inv.is_empty()) self.assertEqual(0, len(inv)) inv = Inventory([P('100.00 USD'), P('101.00 USD')]) self.assertFalse(inv.is_empty()) self.assertEqual(1, len(inv)) inv = Inventory([P('100.00 USD'), P('100.00 CAD')]) self.assertFalse(inv.is_empty()) self.assertEqual(2, len(inv)) inv = Inventory() self.assertEqual(0, len(inv)) inv.add_amount(A('100 USD')) self.assertEqual(1, len(inv)) inv.add_amount(A('100 CAD')) self.assertEqual(2, len(inv))
def test_sum_inventories(self): inv1 = Inventory() inv1.add_amount(A('10 USD')) inv2 = Inventory() inv2.add_amount(A('20 CAD')) inv2.add_amount(A('55 HOOL')) _ = inv1 + inv2
def test_add_amount__zero(self): inv = Inventory() inv.add_amount(A('0 USD')) self.assertEqual(0, len(inv))
def test_add_amount(self): inv = Inventory() inv.add_amount(A('100.00 USD')) self.checkAmount(inv, '100', 'USD') # Add some amount inv.add_amount(A('25.01 USD')) self.checkAmount(inv, '125.01', 'USD') # Subtract some amount. inv.add_amount(A('-12.73 USD')) self.checkAmount(inv, '112.28', 'USD') # Subtract some to be negative (should be allowed if no lot). inv.add_amount(A('-120 USD')) self.checkAmount(inv, '-7.72', 'USD') # Subtract some more. inv.add_amount(A('-1 USD')) self.checkAmount(inv, '-8.72', 'USD') # Add to above zero again inv.add_amount(A('18.72 USD')) self.checkAmount(inv, '10', 'USD')
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)
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. tolerances = interpolate.infer_tolerances(postings, options_map) # Process all the postings. has_nonzero_amount = False has_regular_postings = False for i, posting in enumerate(postings): units = posting.units if units is MISSING or units is None: # This posting will have to get auto-completed. auto_postings_indices.append(i) else: currencies.add(units.currency) # Compute the amount to balance and update the inventory. weight = convert.get_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( SimpleBookingError(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( SimpleBookingError(entry.meta, "Useless auto-posting: {}".format(residual), entry)) for currency in currencies: units = Amount(ZERO, currency) meta = copy.copy(old_posting.meta) if old_posting.meta else {} meta[interpolate.AUTOMATIC_META] = True new_postings.append( Posting(old_posting.account, units, None, 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 pos in residual_positions: pos = -pos units = pos.units new_units = Amount( interpolate.quantize_with_tolerance( tolerances, units.currency, units.number), units.currency) meta = copy.copy(old_posting.meta) if old_posting.meta else {} meta[interpolate.AUTOMATIC_META] = True new_postings.append( Posting(old_posting.account, new_units, pos.cost, None, old_posting.flag, meta)) has_inserted = True # Note/FIXME: This is dumb; refactor cost computation so we can # reuse it directly. new_pos = Position(new_units, pos.cost) # Update the residuals inventory. weight = convert.get_cost(new_pos) 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)