def test_quantities(self): pos = Position(A("10 USD"), None) self.assertEqual(A('10 USD'), pos.units) self.assertEqual(A('10 USD'), convert.get_cost(pos)) pos = Position(A("10 USD"), Cost(D('1.5'), 'AUD', None, None)) self.assertEqual(A('10 USD'), pos.units) self.assertEqual(A('15 AUD'), convert.get_cost(pos))
def average(self): """Average all lots of the same currency together. Returns: An instance of Inventory. """ groups = collections.defaultdict(list) for position in self: key = (position.units.currency, position.cost.currency if position.cost else None) groups[key].append(position) average_inventory = Inventory() for (currency, cost_currency), positions in groups.items(): total_units = sum(position.units.number for position in positions) units_amount = Amount(total_units, currency) if cost_currency: total_cost = sum( convert.get_cost(position).number for position in positions) cost = Cost(total_cost / total_units, cost_currency, None, None) else: cost = None average_inventory.add_amount(units_amount, cost) return average_inventory
def get_holdings_entries(entries, options_map): """Summarizes the entries to list of entries representing the final holdings.. This list includes the latest prices entries as well. This can be used to load a full snapshot of holdings without including the entire history. This is a way of summarizing a balance sheet in a way that filters away history. Args: entries: A list of directives. options_map: A dict of parsed options. Returns: A string, the entries to print out. """ # The entries will be created at the latest date, against an equity account. latest_date = entries[-1].date _, equity_account, _ = options.get_previous_accounts(options_map) # Get all the assets. holdings_list, _ = holdings.get_assets_holdings(entries, options_map) # Create synthetic entries for them. holdings_entries = [] for index, holding in enumerate(holdings_list): meta = data.new_metadata('report_holdings_print', index) entry = data.Transaction(meta, latest_date, flags.FLAG_SUMMARIZE, None, "", None, None, []) # Convert the holding to a position. pos = holdings.holding_to_position(holding) entry.postings.append( data.Posting(holding.account, pos.units, pos.cost, None, None, None)) cost = -convert.get_cost(pos) entry.postings.append( data.Posting(equity_account, cost, None, None, None, None)) holdings_entries.append(entry) # Get opening directives for all the accounts. used_accounts = {holding.account for holding in holdings_list} open_entries = summarize.get_open_entries(entries, latest_date) used_open_entries = [ open_entry for open_entry in open_entries if open_entry.account in used_accounts ] # Add an entry for the equity account we're using. meta = data.new_metadata('report_holdings_print', -1) used_open_entries.insert( 0, data.Open(meta, latest_date, equity_account, None, None)) # Get the latest price entries. price_entries = prices.get_last_price_entries(entries, None) return used_open_entries + holdings_entries + price_entries
def create_entries_from_balances(balances, date, source_account, direction, meta, flag, narration_template): """"Create a list of entries from a dict of balances. This method creates a list of new entries to transfer the amounts in the 'balances' dict to/from another account specified in 'source_account'. The balancing posting is created with the equivalent at cost. In other words, if you attempt to balance 10 HOOL {500 USD}, this will synthesize a posting with this position on one leg, and with 5000 USD on the 'source_account' leg. Args: balances: A dict of account name strings to Inventory instances. date: A datetime.date object, the date at which to create the transaction. source_account: A string, the name of the account to pull the balances from. This is the magician's hat to pull the rabbit from. direction: If 'direction' is True, the new entries transfer TO the balances account from the source account; otherwise the new entries transfer FROM the balances into the source account. meta: A dict to use as metadata for the transactions. flag: A string, the flag to use for the transactinos. narration_template: A format string for creating the narration. It is formatted with 'account' and 'date' replacement variables. Returns: A list of newly synthesizes Transaction entries. """ new_entries = [] for account, account_balance in sorted(balances.items()): # Don't create new entries where there is no balance. if account_balance.is_empty(): continue narration = narration_template.format(account=account, date=date) if not direction: account_balance = -account_balance postings = [] new_entry = Transaction(meta, date, flag, None, narration, data.EMPTY_SET, data.EMPTY_SET, postings) for position in account_balance.get_positions(): postings.append( data.Posting(account, position.units, position.cost, None, None, None)) cost = -convert.get_cost(position) postings.append( data.Posting(source_account, cost, None, None, None, None)) new_entries.append(new_entry) return new_entries
def get_neutralizing_postings(curmap, base_account, new_accounts): """Process an entry. Args: curmap: A dict of currency to a list of Postings of this transaction. base_account: A string, the root account name to insert. new_accounts: A set, a mutable accumulator of new account names. Returns: A modified entry, with new postings inserted to rebalance currency trading accounts. """ new_postings = [] for currency, postings in curmap.items(): # Compute the per-currency balance. inv = inventory.Inventory() for posting in postings: inv.add_amount(convert.get_cost(posting)) if inv.is_empty(): new_postings.extend(postings) continue # Re-insert original postings and remove price conversions. # # Note: This may cause problems if the implicit_prices plugin is # configured to run after this one, or if you need the price annotations # for some scripting or serious work. # # FIXME: We need to handle these important cases (they're not frivolous, # this is a prototype), probably by inserting some exceptions with # collaborating code in the booking (e.g. insert some metadata that # disables price conversions on those postings). # # FIXME(2): Ouch! Some of the residual seeps through here, where there # are more than a single currency block. This needs fixing too. You can # easily mitigate some of this to some extent, by excluding transactions # which don't have any price conversion in them. for pos in postings: if pos.price is not None: pos = pos._replace(price=None) new_postings.append(pos) # Insert the currency trading accounts postings. amount = inv.get_only_position().units acc = account.join(base_account, currency) new_accounts.add(acc) new_postings.append(Posting(acc, -amount, None, None, None, None)) return new_postings
def average(self): """Average all lots of the same currency together. Use the minimum date from each aggregated set of lots. Returns: An instance of Inventory. """ groups = collections.defaultdict(list) for position in self: key = (position.units.currency, position.cost.currency if position.cost else None) groups[key].append(position) average_inventory = Inventory() for (currency, cost_currency), positions in groups.items(): total_units = sum(position.units.number for position in positions) # Explicitly skip aggregates when resulting in zero units. if total_units == ZERO: continue units_amount = Amount(total_units, currency) if cost_currency: total_cost = sum(convert.get_cost(position).number for position in positions) cost_number = (Decimal('Infinity') if total_units == ZERO else (total_cost / total_units)) min_date = None for pos in positions: pos_date = pos.cost.date if pos.cost else None if pos_date is not None: min_date = (pos_date if min_date is None else min(min_date, pos_date)) cost = Cost(cost_number, cost_currency, min_date, None) else: cost = None average_inventory.add_amount(units_amount, cost) return average_inventory
def fill_account(entries, unused_options_map, insert_account): """Insert an posting with a default account when there is only a single posting. Args: entries: A list of directives. unused_options_map: A parser options dict. insert_account: A string, the name of the account. Returns: A list of entries, possibly with more Price entries than before, and a list of errors. """ if not account.is_valid(insert_account): return entries, [ FillAccountError( data.new_metadata('<fill_account>', 0), "Invalid account name: '{}'".format(insert_account), None) ] new_entries = [] for entry in entries: if isinstance(entry, data.Transaction) and len(entry.postings) == 1: inv = inventory.Inventory() for posting in entry.postings: if posting.cost is None: inv.add_amount(posting.units) else: inv.add_amount(convert.get_cost(posting)) inv.reduce(convert.get_units) new_postings = list(entry.postings) for pos in inv: new_postings.append( data.Posting(insert_account, -pos.units, None, None, None, None)) entry = entry._replace(postings=new_postings) new_entries.append(entry) return new_entries, []
def test_negative(self): pos = Position(A("28372 USD"), Cost(D('10'), 'AUD', None, None)) negpos = pos.get_negative() self.assertEqual(A('-28372 USD'), negpos.units) self.assertEqual(A('-283720 AUD'), convert.get_cost(negpos))
def position_cost(pos): """Get the cost of a position.""" return convert.get_cost(pos)
def test_cost__missing(self): self.assertEqual( A("100 HOOL"), convert.get_cost( self._pos(A("100 HOOL"), Cost(MISSING, "USD", None, None))))
def test_cost__not_empty(self): self.assertEqual( A("51400.00 USD"), convert.get_cost( self._pos(A("100 HOOL"), Cost(D("514.00"), "USD", None, None))))
def test_cost__empty(self): self.assertEqual(A("100 HOOL"), convert.get_cost(self._pos(A("100 HOOL"), None)))
def __call__(self, context): args = self.eval_args(context) return convert.get_cost(args[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. 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)