예제 #1
0
def abbreviate_account(acc: str, accounts_map: Dict[str, data.Open]):
    """Compute an abbreviated version of the account name."""

    # Get the root of the account by inspecting the "root: TRUE" attribute up
    # the accounts tree.
    racc = acc
    while racc:
        racc = account.parent(racc)
        dopen = accounts_map.get(racc, None)
        if dopen and dopen.meta.get('root', False):
            acc = racc
            break

    # Remove the account type.
    acc = account.sans_root(acc)

    # Remove the two-letter country code if there is one.
    if re.match(r'[A-Z][A-Z]', acc):
        acc = account.sans_root(acc)

    return acc
예제 #2
0
def add_unrealized_gains(entries, options_map, subaccount=None):
    """Insert entries for unrealized capital gains.

    This function inserts entries that represent unrealized gains, at the end of
    the available history. It returns a new list of entries, with the new gains
    inserted. It replaces the account type with an entry in an income account.
    Optionally, it can book the gain in a subaccount of the original and income
    accounts.

    Args:
      entries: A list of data directives.
      options_map: A dict of options, that confirms to beancount.parser.options.
      subaccount: A string, and optional the name of a subaccount to create
        under an account to book the unrealized gain. If this is left to its
        default value, the gain is booked directly in the same account.
    Returns:
      A list of entries, which includes the new unrealized capital gains entries
      at the end, and a list of errors. The new list of entries is still sorted.
    """
    errors = []
    meta = data.new_metadata('<unrealized_gains>', 0)

    account_types = options.get_account_types(options_map)

    # Assert the subaccount name is in valid format.
    if subaccount:
        validation_account = account.join(account_types.assets, subaccount)
        if not account.is_valid(validation_account):
            errors.append(
                UnrealizedError(meta,
                                "Invalid subaccount name: '{}'".format(subaccount),
                                None))
            return entries, errors

    if not entries:
        return (entries, errors)

    # Group positions by (account, cost, cost_currency).
    price_map = prices.build_price_map(entries)
    holdings_list = holdings.get_final_holdings(entries, price_map=price_map)

    # Group positions by (account, cost, cost_currency).
    holdings_list = holdings.aggregate_holdings_by(
        holdings_list, lambda h: (h.account, h.currency, h.cost_currency))

    # Get the latest prices from the entries.
    price_map = prices.build_price_map(entries)

    # Create transactions to account for each position.
    new_entries = []
    latest_date = entries[-1].date
    for index, holding in enumerate(holdings_list):
        if (holding.currency == holding.cost_currency or
            holding.cost_currency is None):
            continue

        # Note: since we're only considering positions held at cost, the
        # transaction that created the position *must* have created at least one
        # price point for that commodity, so we never expect for a price not to
        # be available, which is reasonable.
        if holding.price_number is None:
            # An entry without a price might indicate that this is a holding
            # resulting from leaked cost basis. {0ed05c502e63, b/16}
            if holding.number:
                errors.append(
                    UnrealizedError(meta,
                                    "A valid price for {h.currency}/{h.cost_currency} "
                                    "could not be found".format(h=holding), None))
            continue

        # Compute the PnL; if there is no profit or loss, we create a
        # corresponding entry anyway.
        pnl = holding.market_value - holding.book_value
        if holding.number == ZERO:
            # If the number of units sum to zero, the holdings should have been
            # zero.
            errors.append(
                UnrealizedError(
                    meta,
                    "Number of units of {} in {} in holdings sum to zero "
                    "for account {} and should not".format(
                        holding.currency, holding.cost_currency, holding.account),
                    None))
            continue

        # Compute the name of the accounts and add the requested subaccount name
        # if requested.
        asset_account = holding.account
        income_account = account.join(account_types.income,
                                      account.sans_root(holding.account))
        if subaccount:
            asset_account = account.join(asset_account, subaccount)
            income_account = account.join(income_account, subaccount)

        # Create a new transaction to account for this difference in gain.
        gain_loss_str = "gain" if pnl > ZERO else "loss"
        narration = ("Unrealized {} for {h.number} units of {h.currency} "
                     "(price: {h.price_number:.4f} {h.cost_currency} as of {h.price_date}, "
                     "average cost: {h.cost_number:.4f} {h.cost_currency})").format(
                         gain_loss_str, h=holding)
        entry = data.Transaction(data.new_metadata(meta["filename"], lineno=1000 + index),
                                 latest_date, flags.FLAG_UNREALIZED,
                                 None, narration, EMPTY_SET, EMPTY_SET, [])

        # Book this as income, converting the account name to be the same, but as income.
        # Note: this is a rather convenient but arbitrary choice--maybe it would be best to
        # let the user decide to what account to book it, but I don't a nice way to let the
        # user specify this.
        #
        # Note: we never set a price because we don't want these to end up in Conversions.
        entry.postings.extend([
            data.Posting(
                asset_account,
                amount.Amount(pnl, holding.cost_currency),
                None,
                None,
                None,
                None),
            data.Posting(
                income_account,
                amount.Amount(-pnl, holding.cost_currency),
                None,
                None,
                None,
                None)
        ])

        new_entries.append(entry)

    # Ensure that the accounts we're going to use to book the postings exist, by
    # creating open entries for those that we generated that weren't already
    # existing accounts.
    new_accounts = {posting.account
                    for entry in new_entries
                    for posting in entry.postings}
    open_entries = getters.get_account_open_close(entries)
    new_open_entries = []
    for account_ in sorted(new_accounts):
        if account_ not in open_entries:
            meta = data.new_metadata(meta["filename"], index)
            open_entry = data.Open(meta, latest_date, account_, None, None)
            new_open_entries.append(open_entry)

    return (entries + new_open_entries + new_entries, errors)
예제 #3
0
 def test_sans_root(self):
     self.assertEqual("Toys:Computer",
                      account.sans_root("Expenses:Toys:Computer"))
     self.assertEqual("US:BofA:Checking",
                      account.sans_root("Assets:US:BofA:Checking"))
     self.assertEqual("", account.sans_root("Assets"))
예제 #4
0
    def option(self, filename, lineno, key, value):
        """Process an option directive.

        Args:
          filename: current filename.
          lineno: current line number.
          key: option's key (str)
          value: option's value
        """
        if key not in self.options:
            meta = new_metadata(filename, lineno)
            self.errors.append(
                ParserError(meta, "Invalid option: '{}'".format(key), None))

        elif key in options.READ_ONLY_OPTIONS:
            meta = new_metadata(filename, lineno)
            self.errors.append(
                ParserError(meta, "Option '{}' may not be set".format(key),
                            None))

        else:
            option_descriptor = options.OPTIONS[key]

            # Issue a warning if the option is deprecated.
            if option_descriptor.deprecated:
                meta = new_metadata(filename, lineno)
                self.errors.append(
                    DeprecatedError(meta, option_descriptor.deprecated, None))

            # Convert the value, if necessary.
            if option_descriptor.converter:
                try:
                    value = option_descriptor.converter(value)
                except ValueError as exc:
                    meta = new_metadata(filename, lineno)
                    self.errors.append(
                        ParserError(
                            meta, "Error for option '{}': {}".format(key, exc),
                            None))
                    return

            option = self.options[key]
            if isinstance(option, list):
                # Append to a list of values.
                option.append(value)

            elif isinstance(option, dict):
                # Set to a dict of values.
                if not (isinstance(value, tuple) and len(value) == 2):
                    self.errors.append(
                        ParserError(
                            meta,
                            "Error for option '{}': {}".format(key,
                                                               value), None))
                    return
                dict_key, dict_value = value
                option[dict_key] = dict_value

            elif isinstance(option, bool):
                # Convert to a boolean.
                if not isinstance(value, bool):
                    value = (value.lower() in {'true', 'on'}) or (value == '1')
                self.options[key] = value

            else:
                # Fix up account_rounding to be a subaccount if the user specified a
                # full account name. This is intended to ease transition in the
                # change of semantics that occurred on 2015-09-05, whereby the value
                # of this option became defined as a subaccount of Equity instead of
                # a full account name. See Issue #67.
                # This should eventually be deprecated, say, in a year (after Sep 2016).
                if key == 'account_rounding':
                    root = account.root(1, value)
                    if root in (self.options['name_{}'.format(name)]
                                for name in [
                                    'assets', 'liabilities', 'equity',
                                    'income', 'expenses'
                                ]):
                        self.errors.append(
                            ParserError(
                                self.get_lexer_location(),
                                "'account_rounding' option should now refer to "
                                "a subaccount.", None))
                        value = account.sans_root(value)

                # Set the value.
                self.options[key] = value

            # Refresh the list of valid account regexps as we go along.
            if key.startswith('name_'):
                # Update the set of valid account types.
                self.account_regexp = valid_account_regexp(self.options)
예제 #5
0
def spread(entry, config_obj):
    # computes the speaded version of a transaction.
    # i.e. distributing yearly PnL reports over the months

    # make emptly list of entries & errors
    entries = []
    errors = []

    # get info from incoming entry
    asset_posting = get_asset(entry)
    income_posting = get_income(entry)
    claim_account = config_obj['liability_acc_base'] + \
        acc.sans_root(income_posting.account)
    units = asset_posting.units
    value = units.number
    currency = units.currency
    amount = Amount(value, currency)

    # make claim posting for final entry
    claim_posting = data.Posting(account=claim_account,
                                 units=-amount,
                                 cost=None,
                                 price=None,
                                 flag=None,
                                 meta=None)

    # make final entry
    trans_orig = data.Transaction(meta=entry.meta,
                                  date=entry.date,
                                  flag=entry.flag,
                                  payee=entry.payee,
                                  narration=entry.narration,
                                  tags=entry.tags,
                                  links=entry.links,
                                  postings=[claim_posting, asset_posting])

    entries.append(trans_orig)

    # make spread-out transactions
    # number of divisions
    n_divides = int(entry.meta['p_spreading_times'])

    # list of dates
    dates = pd.date_range(entry.meta['p_spreading_start'],
                          periods=n_divides,
                          freq=entry.meta['p_spreading_frequency'])
    dates = [x.date() for x in list(dates)]

    # list of values. pay attentino to decimal rounding
    splits = [round(value / n_divides, 2) for i in range(n_divides - 1)]
    splits.append(value - sum(splits))

    # make transactions
    for date, split in zip(dates, splits):

        amount = Amount(split, currency)
        # income leg
        pnl = data.Posting(account=income_posting.account,
                           units=-amount,
                           cost=None,
                           price=None,
                           flag=None,
                           meta=None)
        claim = data.Posting(account=claim_account,
                             units=amount,
                             cost=None,
                             price=None,
                             flag=None,
                             meta=None)

        dropkeys = [
            'p_spreading_times', 'p_spreading_start', 'p_spreading_frequency'
        ]
        meta = {
            key: val
            for key, val in entry.meta.items() if key not in dropkeys
        }
        meta.update({
            'p_spreading':
            f"split {value} into {n_divides} chunks, {entry.meta['p_spreading_frequency']}"
        })
        trans = data.Transaction(meta=meta,
                                 date=date,
                                 flag='*',
                                 payee=entry.payee,
                                 narration=entry.narration,
                                 tags=entry.tags,
                                 links=entry.links,
                                 postings=[pnl, claim])
        entries.append(trans)

    return entries, errors
예제 #6
0
def add_unrealized_gains_at_date(entries, unrealized_entries,
                                 income_account_type, price_map, date, meta,
                                 subaccount):
    """Insert/remove entries for unrealized capital gains 

    This function takes a list of entries and a date and creates a set of unrealized gains
    transactions, negating previous unrealized gains transactions within the same account.

    Args:
      entries: A list of data directives.
      unrealized_entries: A list of previously generated unrealized transactions.
      income_account_type: The income account type.
      price_map: A price map returned by prices.build_price_map.
      date: The effective date to generate the unrealized transactions for.
      meta: meta.
      subaccount: A string, and optional the name of a subaccount to create
        under an account to book the unrealized gain. If this is left to its
        default value, the gain is booked directly in the same account.
    Returns:
      A list of newly created unrealized transactions and a list of errors.
    """
    errors = []

    entries_truncated = summarize.truncate(entries, date + ONEDAY)

    holdings_list = holdings.get_final_holdings(entries_truncated,
                                                price_map=price_map,
                                                date=date)

    # Group positions by (account, cost, cost_currency).
    holdings_list = holdings.aggregate_holdings_by(
        holdings_list, lambda h: (h.account, h.currency, h.cost_currency))

    holdings_with_currencies = set()

    # Create transactions to account for each position.
    new_entries = []
    for index, holding in enumerate(holdings_list):
        if (holding.currency == holding.cost_currency
                or holding.cost_currency is None):
            continue

        # Note: since we're only considering positions held at cost, the
        # transaction that created the position *must* have created at least one
        # price point for that commodity, so we never expect for a price not to
        # be available, which is reasonable.
        if holding.price_number is None:
            # An entry without a price might indicate that this is a holding
            # resulting from leaked cost basis. {0ed05c502e63, b/16}
            if holding.number:
                errors.append(
                    UnrealizedError(
                        meta,
                        "A valid price for {h.currency}/{h.cost_currency} "
                        "could not be found".format(h=holding), None))
            continue

        # Compute the PnL; if there is no profit or loss, we create a
        # corresponding entry anyway.
        pnl = holding.market_value - holding.book_value
        if holding.number == ZERO:
            # If the number of units sum to zero, the holdings should have been
            # zero.
            errors.append(
                UnrealizedError(
                    meta,
                    "Number of units of {} in {} in holdings sum to zero "
                    "for account {} and should not".format(
                        holding.currency, holding.cost_currency,
                        holding.account), None))
            continue

        # Compute the name of the accounts and add the requested subaccount name
        # if requested.
        asset_account = holding.account
        income_account = account.join(income_account_type,
                                      account.sans_root(holding.account))
        if subaccount:
            asset_account = account.join(asset_account, subaccount)
            income_account = account.join(income_account, subaccount)

        holdings_with_currencies.add(
            (holding.account, holding.cost_currency, holding.currency))

        # Find the previous unrealized gain entry to negate and decide if we
        # should create a new posting.
        latest_unrealized_entry = find_previous_unrealized_transaction(
            unrealized_entries, asset_account, holding.cost_currency,
            holding.currency)

        # Don't create a new transaction if our last one hasn't changed.
        if (latest_unrealized_entry
                and pnl == latest_unrealized_entry.postings[0].units.number):
            continue

        # Don't bother creating a blank unrealized transaction if none existed
        if pnl == ZERO and not latest_unrealized_entry:
            continue

        relative_pnl = pnl
        if latest_unrealized_entry:
            relative_pnl = pnl - latest_unrealized_entry.postings[
                0].units.number

        # Create a new transaction to account for this difference in gain.
        gain_loss_str = "gain" if relative_pnl > ZERO else "loss"
        narration = (
            "Unrealized {} for {h.number} units of {h.currency} "
            "(price: {h.price_number:.4f} {h.cost_currency} as of {h.price_date}, "
            "average cost: {h.cost_number:.4f} {h.cost_currency})").format(
                gain_loss_str, h=holding)
        entry = data.Transaction(
            data.new_metadata(meta["filename"],
                              lineno=1000 + index,
                              kvlist={'prev_currency': holding.currency}),
            date, flags.FLAG_UNREALIZED, None, narration, set(), set(), [])

        # Book this as income, converting the account name to be the same, but as income.
        # Note: this is a rather convenient but arbitraty choice--maybe it would be best to
        # let the user decide to what account to book it, but I don't a nice way to let the
        # user specify this.
        #
        # Note: we never set a price because we don't want these to end up in Conversions.
        entry.postings.extend([
            data.Posting(asset_account,
                         amount.Amount(pnl, holding.cost_currency), None, None,
                         None, None),
            data.Posting(income_account,
                         amount.Amount(-pnl, holding.cost_currency), None,
                         None, None, None)
        ])
        if latest_unrealized_entry:
            for posting in latest_unrealized_entry.postings[:2]:
                entry.postings.append(
                    data.Posting(posting.account, -posting.units, None, None,
                                 None, None))

        new_entries.append(entry)

    return new_entries, holdings_with_currencies, errors
def add_unrealized_gains_at_date(entries, unrealized_entries, income_account_type,
                                 price_map, date, meta, subaccount):
    """Insert/remove entries for unrealized capital gains 

    This function takes a list of entries and a date and creates a set of unrealized gains
    transactions, negating previous unrealized gains transactions within the same account.

    Args:
      entries: A list of data directives.
      unrealized_entries: A list of previously generated unrealized transactions.
      income_account_type: The income account type.
      price_map: A price map returned by prices.build_price_map.
      date: The effective date to generate the unrealized transactions for.
      meta: meta.
      subaccount: A string, and optional the name of a subaccount to create
        under an account to book the unrealized gain. If this is left to its
        default value, the gain is booked directly in the same account.
    Returns:
      A list of newly created unrealized transactions and a list of errors.
    """
    errors = []

    entries_truncated = summarize.truncate(entries, date + ONEDAY)

    holdings_list = holdings.get_final_holdings(entries_truncated, price_map=price_map, date=date)

    # Group positions by (account, cost, cost_currency).
    holdings_list = holdings.aggregate_holdings_by(
        holdings_list, lambda h: (h.account, h.currency, h.cost_currency))

    holdings_with_currencies = set()

    # Create transactions to account for each position.
    new_entries = []
    for index, holding in enumerate(holdings_list):
        if (holding.currency == holding.cost_currency or
            holding.cost_currency is None):
            continue

        # Note: since we're only considering positions held at cost, the
        # transaction that created the position *must* have created at least one
        # price point for that commodity, so we never expect for a price not to
        # be available, which is reasonable.
        if holding.price_number is None:
            # An entry without a price might indicate that this is a holding
            # resulting from leaked cost basis. {0ed05c502e63, b/16}
            if holding.number:
                errors.append(
                    UnrealizedError(meta,
                                    "A valid price for {h.currency}/{h.cost_currency} "
                                    "could not be found".format(h=holding), None))
            continue

        # Compute the PnL; if there is no profit or loss, we create a
        # corresponding entry anyway.
        pnl = holding.market_value - holding.book_value
        if holding.number == ZERO:
            # If the number of units sum to zero, the holdings should have been
            # zero.
            errors.append(
                UnrealizedError(
                    meta,
                    "Number of units of {} in {} in holdings sum to zero "
                    "for account {} and should not".format(
                        holding.currency, holding.cost_currency, holding.account),
                    None))
            continue

        # Compute the name of the accounts and add the requested subaccount name
        # if requested.
        asset_account = holding.account
        income_account = account.join(income_account_type,
                                      account.sans_root(holding.account))
        if subaccount:
            asset_account = account.join(asset_account, subaccount)
            income_account = account.join(income_account, subaccount)

        holdings_with_currencies.add((holding.account, holding.cost_currency, holding.currency))

        # Find the previous unrealized gain entry to negate and decide if we
        # should create a new posting.
        latest_unrealized_entry = find_previous_unrealized_transaction(unrealized_entries, asset_account, holding.cost_currency, holding.currency)

        # Don't create a new transaction if our last one hasn't changed.
        if (latest_unrealized_entry and
            pnl == latest_unrealized_entry.postings[0].units.number):
            continue

        # Don't bother creating a blank unrealized transaction if none existed
        if pnl == ZERO and not latest_unrealized_entry:
            continue

        relative_pnl = pnl
        if latest_unrealized_entry:
            relative_pnl = pnl - latest_unrealized_entry.postings[0].units.number

        # Create a new transaction to account for this difference in gain.
        gain_loss_str = "gain" if relative_pnl > ZERO else "loss"
        narration = ("Unrealized {} for {h.number} units of {h.currency} "
                     "(price: {h.price_number:.4f} {h.cost_currency} as of {h.price_date}, "
                     "average cost: {h.cost_number:.4f} {h.cost_currency})").format(
                         gain_loss_str, h=holding)
        entry = data.Transaction(data.new_metadata(meta["filename"], lineno=1000 + index,
                                 kvlist={'prev_currency': holding.currency}), date,
                                 flags.FLAG_UNREALIZED, None, narration, set(), set(), [])

        # Book this as income, converting the account name to be the same, but as income.
        # Note: this is a rather convenient but arbitraty choice--maybe it would be best to
        # let the user decide to what account to book it, but I don't a nice way to let the
        # user specify this.
        #
        # Note: we never set a price because we don't want these to end up in Conversions.
        entry.postings.extend([
            data.Posting(
                asset_account,
                amount.Amount(pnl, holding.cost_currency),
                None,
                None,
                None,
                None),
            data.Posting(
                income_account,
                amount.Amount(-pnl, holding.cost_currency),
                None,
                None,
                None,
                None)
        ])
        if latest_unrealized_entry:
            for posting in latest_unrealized_entry.postings[:2]:
                entry.postings.append(
                    data.Posting(
                        posting.account,
                        -posting.units,
                        None,
                        None,
                        None,
                        None))

        new_entries.append(entry)

    return new_entries, holdings_with_currencies, errors