Example #1
0
 def test_get_entry_accounts(self):
     entries = loader.load_string(TEST_INPUT)[0]
     accounts = getters.get_entry_accounts(
         next(entry for entry in entries
              if isinstance(entry, data.Transaction)))
     self.assertEqual(
         {'Assets:US:Cash', 'Expenses:Grocery', 'Expenses:Restaurant'},
         accounts)
Example #2
0
def validate_active_accounts(entries, unused_options_map):
    """Check that all references to accounts occurs on active accounts.

    We basically check that references to accounts from all directives other
    than Open and Close occur at dates the open-close interval of that account.
    This should be good for all of the directive types where we can extract an
    account name.

    Note that this is more strict a check than comparing the dates: we actually
    check that no references to account are made on the same day before the open
    directive appears for that account. This is a nice property to have, and is
    supported by our custom sorting routine that will sort open entries before
    transaction entries, given the same date.

    Args:
      entries: A list of directives.
      unused_options_map: An options map.
    Returns:
      A list of new errors, if any were found.
    """
    error_pairs = []
    active_set = set()
    opened_accounts = set()
    for entry in entries:
        if isinstance(entry, data.Open):
            active_set.add(entry.account)
            opened_accounts.add(entry.account)

        elif isinstance(entry, data.Close):
            active_set.discard(entry.account)

        else:
            for account in getters.get_entry_accounts(entry):
                if account not in active_set:
                    # Allow document and note directives that occur after an
                    # account is closed.
                    if (isinstance(entry, ALLOW_AFTER_CLOSE)
                            and account in opened_accounts):
                        continue

                    # Register an error to be logged later, with an appropriate
                    # message.
                    error_pairs.append((account, entry))

    # Refine the error message to disambiguate between the case of an account
    # that has never been seen and one that was simply not active at the time.
    errors = []
    for account, entry in error_pairs:
        if account in opened_accounts:
            message = "Invalid reference to inactive account '{}'".format(
                account)
        else:
            message = "Invalid reference to unknown account '{}'".format(
                account)
        errors.append(ValidationError(entry.meta, message, entry))

    return errors
Example #3
0
def compute_entry_context(entries, context_entry, additional_accounts=None):
    """Compute the balances of all accounts referenced by entry up to entry.

    This provides the inventory of the accounts to which the entry is to be
    applied, before and after.

    Args:
      entries: A list of directives.
      context_entry: The entry for which we want to obtain the before and after
        context.
      additional_accounts: Additional list of accounts to include in calculating
        the balance. This is used when invoked for debugging, in case the booked
        & interpolated transaction doesn't have all the accounts we need because
        it had an error (the booking code will remove invalid postings).
    Returns:
      Two dicts of account-name to Inventory instance, one which represents the
      context before the entry is applied, and one that represents the context
      after it has been applied.
    """
    assert context_entry is not None, "context_entry is missing."

    # Get the set of accounts for which to compute the context.
    context_accounts = getters.get_entry_accounts(context_entry)
    if additional_accounts:
        context_accounts.update(additional_accounts)

    # Iterate over the entries until we find the target one and accumulate the
    # balance.
    context_before = collections.defaultdict(inventory.Inventory)
    for entry in entries:
        if entry is context_entry:
            break
        if isinstance(entry, Transaction):
            for posting in entry.postings:
                if not any(posting.account == account
                           for account in context_accounts):
                    continue
                balance = context_before[posting.account]
                balance.add_position(posting)

    # Compute the after context for the entry.
    context_after = copy.deepcopy(context_before)
    if isinstance(context_entry, Transaction):
        for posting in entry.postings:
            balance = context_after[posting.account]
            balance.add_position(posting)

    return context_before, context_after
Example #4
0
def compute_entry_context(entries, context_entry):
    """Compute the balances of all accounts referenced by entry up to entry.

    This provides the inventory of the accounts to which the entry is to be
    applied, before and after.

    Args:
      entries: A list of directives.
      context_entry: The entry for which we want to obtain the before and after
        context.
    Returns:
      Two dicts of account-name to Inventory instance, one which represents the
      context before the entry is applied, and one that represents the context
      after it has been applied.
    """
    assert context_entry is not None, "context_entry is missing."

    # Get the set of accounts for which to compute the context.
    context_accounts = getters.get_entry_accounts(context_entry)

    # Iterate over the entries until we find the target one and accumulate the
    # balance.
    context_before = collections.defaultdict(inventory.Inventory)
    for entry in entries:
        if entry is context_entry:
            break
        if isinstance(entry, Transaction):
            for posting in entry.postings:
                if not any(posting.account == account
                           for account in context_accounts):
                    continue
                balance = context_before[posting.account]
                balance.add_position(posting.position)

    # Compute the after context for the entry.
    context_after = copy.deepcopy(context_before)
    if isinstance(context_entry, Transaction):
        for posting in entry.postings:
            balance = context_after[posting.account]
            balance.add_position(posting.position)

    return context_before, context_after
Example #5
0
def validate_unused_accounts(entries, unused_options_map):
    """Check that all accounts declared open are actually used.

    We check that all of the accounts that are open are at least referred to by
    another directive. These are probably unused, so issue a warning (we like to
    be pedantic). Note that an account that is open and then closed is
    considered used--this is a valid use case that may occur in reality. If you
    have a use case for an account to be open but never used, you can quiet that
    warning by initializing the account with a balance asserts or a pad
    directive, or even use a note will be sufficient.

    (This is probably a good candidate for optional inclusion as a "pedantic"
    plugin.)

    Args:
      entries: A list of directives.
      unused_options_map: An options map.
    Returns:
      A list of new errors, if any were found.
    """
    # Find all the accounts referenced by entries which are not Open, and the
    # open directives for error reporting below.
    open_map = {}
    referenced_accounts = set()
    for entry in entries:
        if isinstance(entry, data.Open):
            open_map[entry.account] = entry
            continue
        referenced_accounts.update(getters.get_entry_accounts(entry))

    # Create a list of suitable errors, with the location of the Open directives
    # corresponding to the unused accounts.
    errors = [
        UnusedAccountError(open_entry.meta,
                           "Unused account '{}'".format(account), open_entry)
        for account, open_entry in open_map.items()
        if account not in referenced_accounts
    ]
    return entries, errors
def render_entry_context(entries, options_map, entry):
    """Render the context before and after a particular transaction is applied.

    Args:
      entries: A list of directives.
      options_map: A dict of options, as produced by the parser.
      entry: The entry instance which should be rendered. (Note that this object is
        expected to be in the set of entries, not just structurally equal.)
    Returns:
      A multiline string of text, which consists of the context before the
      transaction is applied, the transaction itself, and the context after it
      is applied. You can just print that, it is in form that is intended to be
      consumed by the user.
    """
    oss = io.StringIO()

    meta = entry.meta
    print("Hash:{}".format(compare.hash_entry(entry)), file=oss)
    print("Location: {}:{}".format(meta["filename"], meta["lineno"]), file=oss)

    # Get the list of accounts sorted by the order in which they appear in the
    # closest entry.
    order = {}
    if isinstance(entry, data.Transaction):
        order = {
            posting.account: index
            for index, posting in enumerate(entry.postings)
        }
    accounts = sorted(getters.get_entry_accounts(entry),
                      key=lambda account: order.get(account, 10000))

    # Accumulate the balances of these accounts up to the entry.
    balance_before, balance_after = interpolate.compute_entry_context(
        entries, entry)

    # Create a format line for printing the contents of account balances.
    max_account_width = max(map(len, accounts)) if accounts else 1
    position_line = '{{:1}} {{:{width}}}  {{:>49}}'.format(
        width=max_account_width)

    # Print the context before.
    print(file=oss)
    print("------------ Balances before transaction", file=oss)
    print(file=oss)
    before_hashes = set()
    for account in accounts:
        positions = balance_before[account].get_positions()
        for position in positions:
            before_hashes.add((account, hash(position)))
            print(position_line.format('', account, str(position)), file=oss)
        if not positions:
            print(position_line.format('', account, ''), file=oss)
        print(file=oss)

    # Print the entry itself.
    print(file=oss)
    print("------------ Transaction", file=oss)
    print(file=oss)
    dcontext = options_map['dcontext']
    printer.print_entry(entry, dcontext, render_weights=True, file=oss)

    if isinstance(entry, data.Transaction):
        print(file=oss)

        # Print residuals.
        residual = interpolate.compute_residual(entry.postings)
        if not residual.is_empty():
            # Note: We render the residual at maximum precision, for debugging.
            print('Residual: {}'.format(residual), file=oss)

        # Dump the tolerances used.
        tolerances = interpolate.infer_tolerances(entry.postings, options_map)
        if tolerances:
            print('Tolerances: {}'.format(', '.join(
                '{}={}'.format(key, value)
                for key, value in sorted(tolerances.items()))),
                  file=oss)

        # Compute the total cost basis.
        cost_basis = inventory.Inventory(pos for pos in entry.postings
                                         if pos.cost is not None).reduce(
                                             convert.get_cost)
        if not cost_basis.is_empty():
            print('Basis: {}'.format(cost_basis), file=oss)

    # Print the context after.
    print(file=oss)
    print("------------ Balances after transaction", file=oss)
    print(file=oss)
    for account in accounts:
        positions = balance_after[account].get_positions()
        for position in positions:
            changed = (account, hash(position)) not in before_hashes
            print(position_line.format('*' if changed else '', account,
                                       str(position)),
                  file=oss)
        if not positions:
            print(position_line.format('', account, ''), file=oss)
        print(file=oss)

    return oss.getvalue()
Example #7
0
def split_income(entries, options_map, config_str):
    """Split income transactions."""
    # pylint: disable=not-callable,too-many-locals

    errors = []
    new_entries = []
    new_accounts = set()
    config = {
        'income': options_map['name_income'],
        'net_income': options_map['name_income'] + ':Net',
        'tag': 'pretax',
        'taxes': options_map['name_expenses'] + ':Taxes',
    }

    if config_str.strip():
        try:
            expr = ast.literal_eval(config_str)
            config.update(expr)
        except (SyntaxError, ValueError):
            errors.append(
                SplitIncomeError(
                    data.new_metadata(options_map['filename'], 0),
                    "Syntax error in config: {}".format(config_str), None))
            return entries, errors

    for entry in entries:
        if not isinstance(entry, data.Transaction) or not any(
                account.startswith(config['income'])
                for account in getters.get_entry_accounts(entry)):
            continue

        # The new entry containing the raw income and taxes.
        new_entry = copy.deepcopy(entry)
        new_entry = new_entry._replace(
            postings=[], tags=frozenset(set([config['tag']]) | entry.tags))
        new_entries.append(new_entry)

        income = collections.defaultdict(Inventory)
        taxes = collections.defaultdict(Decimal)
        for posting in list(entry.postings):
            if posting.account.startswith(config['income']):
                new_entry.postings.append(posting)
                entry.postings.remove(posting)
                income[posting.account].add_amount(posting.units)
            elif re.match(config['taxes'], posting.account):
                new_entry.postings.append(posting)
                entry.postings.remove(posting)
                taxes[posting.units.currency] += posting.units.number

        for account, inv in income.items():
            net_account = account.replace(config['income'],
                                          config['net_income'])
            if net_account not in new_accounts:
                new_accounts.add(net_account)
                new_entries.append(
                    data.Open(data.new_metadata('<split_income>', 0),
                              entry.date, net_account, None, None))

            for pos in inv:
                amount = pos.units
                number = amount.number + taxes.pop(amount.currency, ZERO)
                data.create_simple_posting(entry, net_account, number,
                                           amount.currency)
                data.create_simple_posting(new_entry, net_account, -number,
                                           amount.currency)

    return entries + new_entries, errors
Example #8
0
def segment_periods(entries,
                    accounts_value,
                    accounts_intflows,
                    date_begin=None,
                    date_end=None):
    """Segment entries in terms of piecewise periods of internal flow.

    This function iterated through the given entries and computes balances at
    the beginning and end of periods without external flow entries. You should be
    able to then compute the returns from these informations.

    Args:
      entries: A list of directives. The list may contain directives other than
        than transactions as well as directives with no relation to the assets or
        internal flow accounts (the function simply ignores that which is not
        relevant).
      accounts_value: A set of the asset accounts in the related group.
      accounts_intflows: A set of the internal flow accounts in the related group.
      date_begin: A datetime.date instance, the beginning date of the period to compute
        returns over.
      date_end: A datetime.date instance, the end date of the period to compute returns
        over.
    Returns:
      A pair of
        periods: A list of period tuples, each of which contains:
          period_begin: A datetime.date instance, the first day of the period.
          period_end: A datetime.date instance, the last day of the period.
          balance_begin: An Inventory instance, the balance at the beginning of the period.
          balance_end: An Inventory instance, the balance at the end of the period.
        portfolio_entries: A list of the entries that we used in computing the portfolio.
    Raises:
      ValueError: If the dates create an impossible situation, the beginning
        must come before the requested end, if specified.
    """
    logging.info("Segmenting periods.")
    logging.info("Date begin: %s", date_begin)
    logging.info("Date end:   %s", date_end)

    if date_begin and date_end and date_begin >= date_end:
        raise ValueError("Dates are not ordered correctly: {} >= {}".format(
            date_begin, date_end))

    accounts_related = accounts_value | accounts_intflows
    is_external_flow_entry = lambda entry: (isinstance(
        entry, data.Transaction) and any(posting.account not in
                                         accounts_related
                                         for posting in entry.postings))

    # Create an iterator over the entries we care about.
    portfolio_entries = [
        entry for entry in entries
        if getters.get_entry_accounts(entry) & accounts_value
    ]
    iter_entries = iter(portfolio_entries)
    entry = next(iter_entries)

    # If a beginning cut-off has been specified, skip the entries before then
    # (and make sure to accumulate the initial balance correctly).
    balance = inventory.Inventory()
    if date_begin is not None:
        period_begin = date_begin
        try:
            while True:
                if entry.date >= date_begin:
                    break
                if date_end and entry.date >= date_end:
                    break
                balance = sum_balances_for_accounts(balance, entry,
                                                    accounts_value)
                entry = next(iter_entries)
        except StopIteration:
            # No periods found! Just return an empty list.
            return [(date_begin, date_end or date_begin, balance, balance)], []
    else:
        period_begin = entry.date

    # Main loop over the entries.
    periods = []
    entry_logger = misc_utils.LineFileProxy(logging.debug, '   ')
    done = False
    while True:
        balance_begin = copy.copy(balance)

        logging.debug(
            ",-----------------------------------------------------------")
        logging.debug(" Begin:   %s", period_begin)
        logging.debug(" Balance: %s", balance_begin.units())
        logging.debug("")

        # Consume all internal flow entries, simply accumulating the total balance.
        while True:
            period_end = entry.date
            if is_external_flow_entry(entry):
                break
            if date_end and entry.date >= date_end:
                period_end = date_end
                done = True
                break
            if entry:
                printer.print_entry(entry, file=entry_logger)
            balance = sum_balances_for_accounts(balance, entry, accounts_value)
            try:
                entry = next(iter_entries)
            except StopIteration:
                done = True
                if date_end:
                    period_end = date_end
                break
        else:
            done = True

        balance_end = copy.copy(balance)

        ## FIXME: Bring this back in, this fails for now. Something about the
        ## initialization fails it. assert period_begin <= period_end,
        ## (period_begin, period_end)
        periods.append((period_begin, period_end, balance_begin, balance_end))

        logging.debug(" Balance: %s", balance_end.units())
        logging.debug(" End:     %s", period_end)
        logging.debug(
            "`-----------------------------------------------------------")
        logging.debug("")

        if done:
            break

        # Absorb the balance of the external flow entry.
        assert is_external_flow_entry(entry), entry
        if entry:
            printer.print_entry(entry, file=entry_logger)
        balance = sum_balances_for_accounts(balance, entry, accounts_value)
        try:
            entry = next(iter_entries)
        except StopIteration:
            # If there is an end date, insert that final period to cover the end
            # date, with no changes.
            if date_end:
                periods.append((period_end, date_end, balance, balance))
            break

        period_begin = period_end

    ## FIXME: Bring this back in, this fails for now.
    # assert all(period_begin <= period_end
    #            for period_begin, period_end, _, _ in periods), periods
    return periods, portfolio_entries
def segment_periods(entries, accounts_value, accounts_internal):
    """Segment entries in terms of piecewise periods of internal flow.

    This function iterates through the given entries and computes balances at
    the beginning and end of periods without external flow entries. You should be
    able to then compute the returns from this information.

    Args:
      entries: A list of directives. The list may contain directives other than
        than transactions as well as directives with no relation to the assets or
        internal flow accounts (the function simply ignores that which is not
        relevant).
      accounts_value: A set of the asset accounts in the related group.
      accounts_internal: A set of the internal flow accounts in the related group.
    Returns:
      A timeline, which is a list of Segment instances.
    Raises:
      ValueError: If the dates create an impossible situation, the beginning
        must come before the requested end, if specified.
    """
    accounts_related = accounts_value | accounts_internal
    is_external_flow_entry = lambda entry: (isinstance(entry, data.Transaction) and
                                            any(posting.account not in accounts_related
                                                for posting in entry.postings))

    # Create an iterator over the entries we care about.
    portfolio_entries = [entry
                         for entry in entries
                         if (isinstance(entry, data.Transaction) and
                             getters.get_entry_accounts(entry) & accounts_value)]
    iter_entries = iter(portfolio_entries)
    entry = next(iter_entries)

    # If a beginning cut-off has been specified, skip the entries before then
    # (and make sure to accumulate the initial balance correctly).
    balance = inventory.Inventory()
    period_begin = entry.date

    # Main loop over the entries.
    timeline = []
    done = False
    while True:
        balance_begin = copy.copy(balance)

        # Consume all internal flow entries, simply accumulating the total balance.
        segment_entries = []
        while True:
            period_end = entry.date
            if is_external_flow_entry(entry):
                break
            if entry:
                segment_entries.append(entry)
            sum_balances_for_accounts(balance, entry, accounts_value)
            try:
                entry = next(iter_entries)
            except StopIteration:
                done = True
                entry = None
                break
        balance_end = copy.copy(balance)

        ## FIXME: Bring this back in, this fails for now. Something about the
        ## initialization fails it.
        ## assert period_begin <= period_end, (period_begin, period_end)

        external_entries = []
        segment = Segment(Snapshot(period_begin, balance_begin),
                          Snapshot(period_end, balance_end),
                          segment_entries, external_entries)
        timeline.append(segment)

        # Absorb the balance of the external flow entries as long as they're on
        # the same date (a date change would imply a possible market value
        # change and we would want to create a segment in such cases, even if
        # the portfolio contents do not change).
        if entry is not None:
            date = entry.date
            while is_external_flow_entry(entry) and entry.date == date:
                external_entries.append(entry)
                sum_balances_for_accounts(balance, entry, accounts_value)
                try:
                    entry = next(iter_entries)
                except StopIteration:
                    done = True
                    break

        if done:
            break

        period_begin = period_end

    ## FIXME: Bring this back in, this fails for now.
    # assert all(period_begin <= period_end
    #            for period_begin, period_end, _, _ in periods), periods
    return timeline
Example #10
0
 def __call__(self, context):
     pattern = self.operands[0](context)
     search = re.compile(pattern, re.IGNORECASE).search
     return any(
         search(account)
         for account in getters.get_entry_accounts(context.entry))
Example #11
0
 def __call__(self, entry):
     pattern = self.eval_args(entry)[0]
     search = re.compile(pattern, re.IGNORECASE).search
     return any(search(account) for account in getters.get_entry_accounts(entry))
Example #12
0
def render_entry_context(entries, options_map, entry, parsed_entry=None):
    """Render the context before and after a particular transaction is applied.

    Args:
      entries: A list of directives.
      options_map: A dict of options, as produced by the parser.
      entry: The entry instance which should be rendered. (Note that this object is
        expected to be in the set of entries, not just structurally equal.)
      parsed_entry: An optional incomplete, parsed but not booked nor interpolated
        entry. If this is provided, this is used for inspecting the list of prior
        accounts and it is also rendered.
    Returns:
      A multiline string of text, which consists of the context before the
      transaction is applied, the transaction itself, and the context after it
      is applied. You can just print that, it is in form that is intended to be
      consumed by the user.
    """
    oss = io.StringIO()
    pr = functools.partial(print, file=oss)
    header = "** {} --------------------------------"

    meta = entry.meta
    pr(header.format("Transaction Id"))
    pr()
    pr("Hash:{}".format(compare.hash_entry(entry)))
    pr("Location: {}:{}".format(meta["filename"], meta["lineno"]))
    pr()
    pr()

    # Get the list of accounts sorted by the order in which they appear in the
    # closest entry.
    order = {}
    if parsed_entry is None:
        parsed_entry = entry
    if isinstance(parsed_entry, data.Transaction):
        order = {
            posting.account: index
            for index, posting in enumerate(parsed_entry.postings)
        }
    accounts = sorted(getters.get_entry_accounts(parsed_entry),
                      key=lambda account: order.get(account, 10000))

    # Accumulate the balances of these accounts up to the entry.
    balance_before, balance_after = interpolate.compute_entry_context(
        entries, entry, additional_accounts=accounts)

    # Create a format line for printing the contents of account balances.
    max_account_width = max(map(len, accounts)) if accounts else 1
    position_line = '{{:1}} {{:{width}}}  {{:>49}}'.format(
        width=max_account_width)

    # Print the context before.
    pr(header.format("Balances before transaction"))
    pr()
    before_hashes = set()
    average_costs = {}
    for account in accounts:
        balance = balance_before[account]

        pc_balances = balance.split()
        for currency, pc_balance in pc_balances.items():
            if len(pc_balance) > 1:
                average_costs[account] = pc_balance.average()

        positions = balance.get_positions()
        for position in positions:
            before_hashes.add((account, hash(position)))
            pr(position_line.format('', account, str(position)))
        if not positions:
            pr(position_line.format('', account, ''))
        pr()
    pr()

    # Print average cost per account, if relevant.
    if average_costs:
        pr(header.format("Average Costs"))
        pr()
        for account, average_cost in sorted(average_costs.items()):
            for position in average_cost:
                pr(position_line.format('', account, str(position)))
        pr()
        pr()

    # Print the entry itself.
    dcontext = options_map['dcontext']
    pr(header.format("Unbooked Transaction"))
    pr()
    if parsed_entry:
        printer.print_entry(parsed_entry,
                            dcontext,
                            render_weights=True,
                            file=oss)
    pr()

    pr(header.format("Transaction"))
    pr()
    printer.print_entry(entry, dcontext, render_weights=True, file=oss)
    pr()

    if isinstance(entry, data.Transaction):
        pr(header.format("Residual and Tolerances"))
        pr()

        # Print residuals.
        residual = interpolate.compute_residual(entry.postings)
        if not residual.is_empty():
            # Note: We render the residual at maximum precision, for debugging.
            pr('Residual: {}'.format(residual))

        # Dump the tolerances used.
        tolerances = interpolate.infer_tolerances(entry.postings, options_map)
        if tolerances:
            pr('Tolerances: {}'.format(', '.join(
                '{}={}'.format(key, value)
                for key, value in sorted(tolerances.items()))))

        # Compute the total cost basis.
        cost_basis = inventory.Inventory(pos for pos in entry.postings
                                         if pos.cost is not None).reduce(
                                             convert.get_cost)
        if not cost_basis.is_empty():
            pr('Basis: {}'.format(cost_basis))
        pr()
        pr()

    # Print the context after.
    pr(header.format("Balances after transaction"))
    pr()
    for account in accounts:
        positions = balance_after[account].get_positions()
        for position in positions:
            changed = (account, hash(position)) not in before_hashes
            print(position_line.format('*' if changed else '', account,
                                       str(position)),
                  file=oss)
        if not positions:
            pr(position_line.format('', account, ''))
        pr()

    return oss.getvalue()