Esempio n. 1
0
    def render_posting_strings(self, posting):
        """This renders the three components of a posting: the account and its optional
        posting flag, the position, and finally, the weight of the position. The
        purpose is to align these in the caller.

        Args:
          posting: An instance of Posting, the posting to render.
        Returns:
          A tuple of
            flag_account: A string, the account name including the flag.
            position_str: A string, the rendered position string.
            weight_str: A string, the rendered weight of the posting.
        """
        # Render a string of the flag and the account.
        flag = '{} '.format(posting.flag) if posting.flag else ''
        flag_account = flag + posting.account

        # Render a string with the amount and cost and optional price, if
        # present. Also render a string with the weight.
        weight_str = ''
        if isinstance(posting.units, amount.Amount):
            position_str = position.to_string(posting, self.dformat)
            # Note: we render weights at maximum precision, for debugging.
            if posting.cost is None or (isinstance(posting.cost, position.Cost)
                                        and isinstance(posting.cost.number,
                                                       Decimal)):
                weight_str = str(convert.get_weight(posting))
        else:
            position_str = ''

        if posting.price is not None:
            position_str += ' @ {}'.format(
                posting.price.to_string(self.dformat_max))

        return flag_account, position_str, weight_str
Esempio n. 2
0
    def test_old_test(self):
        # Entry without cost, without price.
        posting = P(None, "Assets:Bank:Checking", "105.50", "USD")
        self.assertEqual(A("105.50 USD"), convert.get_weight(posting))

        # Entry without cost, with price.
        posting = posting._replace(price=A("0.90 CAD"))
        self.assertEqual(A("94.95 CAD"), convert.get_weight(posting))

        # Entry with cost, without price.
        posting = PCost(None, "Assets:Bank:Checking", "105.50", "USD", "0.80",
                        "EUR")
        self.assertEqual(A("84.40 EUR"), convert.get_weight(posting))

        # Entry with cost, and with price (the price should be ignored).
        posting = posting._replace(price=A("2.00 CAD"))
        self.assertEqual(A("84.40 EUR"), convert.get_weight(posting))
Esempio n. 3
0
def handle_stock_exchange(entry: data.Directive,
                          account: Account) -> List[CashFlow]:
    """This is for a stock exchange, similar to the issuance of GOOG from GOOGL."""
    flows = []
    for posting in entry.postings:
        if posting.meta["category"] == Cat.OTHERASSET:
            cf = CashFlow(entry.date, convert.get_weight(posting), False,
                          "other", account)
            posting.meta["flow"] = cf
            flows.append(cf)
    return flows
Esempio n. 4
0
def produce_cash_flows_general(entry: data.Directive,
                               account: Account) -> List[CashFlow]:
    """Produce cash flows using a generalized rule."""
    has_dividend = any(posting.meta["category"] == Cat.DIVIDEND
                       for posting in entry.postings)
    flows = []
    for posting in entry.postings:
        category = posting.meta["category"]
        if category == Cat.CASH:
            assert not posting.cost
            cf = CashFlow(entry.date, convert.get_weight(posting),
                          has_dividend, "cash", account)
            posting.meta["flow"] = cf
            flows.append(cf)

        elif category == Cat.OTHERASSET:
            # If the account deposits other assets, count this as an outflow.
            cf = CashFlow(entry.date, convert.get_weight(posting), False,
                          "other", account)
            posting.meta["flow"] = cf
            flows.append(cf)

    return flows
Esempio n. 5
0
def handle_failing(entry: data.Directive) -> List[CashFlow]:
    """This is for the GOOG/GOOGL stock exchange."""
    flows = []
    for posting in entry.postings:
        category = posting.meta["category"]
        if category == Cat.ASSET:
            flows.append(
                CashFlow(entry.date, -convert.get_weight(posting), False,
                         None))
        elif category == Cat.OTHERASSET:
            pass
        else:
            raise ValueError("Unsupported category: {}".format(category))
    return flows
Esempio n. 6
0
def balance_amounts(txn: Transaction) -> None:
    """Add FIXME account for the remaing amount to balance accounts"""
    inventory = SimpleInventory()
    for posting in txn.postings:
        inventory += get_weight(convert_costspec_to_cost(posting))
    for currency in inventory:
        txn.postings.append(
            Posting(
                account=FIXME_ACCOUNT,
                units=Amount(currency=currency, number=-inventory[currency]),
                cost=None,
                price=None,
                flag=None,
                meta={},
            ))
def check(entries, options_map):
    errors = []

    for entry in data.filter_txns(entries):
        positivePortfolioSums = defaultdict(Decimal)
        negativePortfolioSums = defaultdict(Decimal)
        for posting in entry.postings:
            if posting.meta and 'portfolio_check_weight' in posting.meta:
                weight = Decimal(posting.meta['portfolio_check_weight'])
            else:
                weight = round(convert.get_weight(posting).number, 2)
            account = posting.account
            portfolio = account.split(':')[1]
            if weight > 0:
                positivePortfolioSums[portfolio] += weight
            else:
                negativePortfolioSums[portfolio] += weight

        portfolios = set(
            list(positivePortfolioSums.keys()) +
            list(negativePortfolioSums.keys()))
        weight = None
        for portfolio in portfolios:
            positiveWeight = positivePortfolioSums[portfolio]
            negativeWeight = -negativePortfolioSums[portfolio]
            if (not isclose(positiveWeight, negativeWeight, abs_tol=0.05)):
                errors.append(
                    NonZeroWeightPerPortfolio(
                        entry.meta,
                        f'Weights for portfolio {portfolio} don\'t equal zero {positiveWeight} != {-negativeWeight}',
                        entry))
            if weight and weight != positiveWeight and 'skip_cross_portfolio_check' not in entry.meta:
                errors.append(
                    DifferentWeightPerPortfolio(
                        entry.meta, 'Not all portfolios have the same weight',
                        entry))
            weight = positiveWeight

    return entries, errors
Esempio n. 8
0
def compute_residual(postings):
    """Compute the residual of a set of complete postings, and the per-currency precision.

    This is used to cross-check a balanced transaction.

    The precision is the maximum fraction that is being used for each currency
    (a dict). We use the currency of the weight amount in order to infer the
    quantization precision for each currency. Integer amounts aren't
    contributing to the determination of precision.

    Args:
      postings: A list of Posting instances.
    Returns:
      An instance of Inventory, with the residual of the given list of postings.
    """
    inventory = Inventory()
    for posting in postings:
        # Skip auto-postings inserted to absorb the residual (rounding error).
        if posting.meta and posting.meta.get(AUTOMATIC_RESIDUAL, False):
            continue
        # Add to total residual balance.
        inventory.add_amount(convert.get_weight(posting))
    return inventory
Esempio n. 9
0
    def render_posting_strings(self, posting):
        """This renders cost-based posting or normal posting
        """
        if not isinstance(posting, CostBasedPosting):
            return super().render_posting_strings(posting)

        from decimal import Decimal
        from beancount.core import position
        from beancount.core import amount
        from beancount.core import convert

        # Render a string of the flag and the account.
        flag = '{} '.format(posting.flag) if posting.flag else ''
        flag_account = flag + posting.account

        # Render a string with the amount and cost and optional price, if
        # present. Also render a string with the weight.
        weight_str = ''
        if isinstance(posting.units, amount.Amount):
            old_posting = data.Posting(posting.account, posting.units,
                                       posting.cost, posting.price,
                                       posting.flag, posting.meta)
            position_str = position.to_string(old_posting, self.dformat)
            # Note: we render weights at maximum precision, for debugging.
            if posting.cost is None or (isinstance(posting.cost, position.Cost)
                                        and isinstance(posting.cost.number,
                                                       Decimal)):
                weight_str = str(convert.get_weight(old_posting))
        else:
            position_str = ''

        if posting.total_cost is not None:
            position_str += ' @@ {}'.format(
                posting.total_cost.to_string(self.dformat_max))

        return flag_account, position_str, weight_str
Esempio n. 10
0
def split_currency_conversions(entry):
    """If the transcation has a mix of conversion at cost and a
    currency conversion, split the transction into two transactions: one
    that applies the currency conversion in the same account, and one
    that uses the other currency without conversion.

    This is required because Ledger does not appear to be able to grok a
    transaction like this one:

      2014-11-02 * "Buy some stock with foreign currency funds"
        Assets:CA:Investment:HOOL          5 HOOL {520.0 USD}
        Expenses:Commissions            9.95 USD
        Assets:CA:Investment:Cash   -2939.46 CAD @ 0.8879 USD

    HISTORICAL NOTE: Adding a price directive on the first posting above makes
    Ledger accept the transaction. So we will not split the transaction here
    now. However, since Ledger's treatment of this type of conflict is subject
    to revision (See http://bugs.ledger-cli.org/show_bug.cgi?id=630), we will
    keep this code around, it might become useful eventually. See
    https://groups.google.com/d/msg/ledger-cli/35hA0Dvhom0/WX8gY_5kHy0J for
    details of the discussion.

    Args:
      entry: An instance of Transaction.
    Returns:
      A pair of
        converted: boolean, true if a conversion was made.
        entries: A list of the original entry if converted was False,
          or a list of the split converted entries if True.
    """
    assert isinstance(entry, data.Transaction)

    (postings_simple, postings_at_price,
     postings_at_cost) = postings_by_type(entry)

    converted = postings_at_cost and postings_at_price
    if converted:
        # Generate a new entry for each currency conversion.
        new_entries = []
        replacement_postings = []
        for posting_orig in postings_at_price:
            weight = convert.get_weight(posting_orig)
            posting_pos = data.Posting(posting_orig.account, weight, None,
                                       None, None, None)
            posting_neg = data.Posting(posting_orig.account, -weight, None,
                                       None, None, None)

            currency_entry = entry._replace(
                postings=[posting_orig, posting_neg],
                narration=entry.narration + ' (Currency conversion)')
            new_entries.append(currency_entry)
            replacement_postings.append(posting_pos)

        converted_entry = entry._replace(postings=(postings_at_cost +
                                                   postings_simple +
                                                   replacement_postings))
        new_entries.append(converted_entry)
    else:
        new_entries = [entry]

    return converted, new_entries
Esempio n. 11
0
def html_entries_table(oss, txn_postings, formatter, render_postings=True):
    """Render a list of entries into an HTML table, with no running balance.

    This is appropriate for rendering tables of entries for postings with
    multiple accounts, whereby computing the running balances makes little
    sense.

    (This function returns nothing, it write to oss as a side-effect.)

    Args:
      oss: A file object to write the output to.
      txn_postings: A list of Posting or directive instances.
      formatter: An instance of HTMLFormatter, to be render accounts,
        inventories, links and docs.
      render_postings: A boolean; if true, render the postings as rows under the
        main transaction row.
    """
    write = lambda data: (oss.write(data), oss.write('\n'))

    write('''
      <table class="entry-table">
      <thead>
        <tr>
         <th class="datecell">Date</th>
         <th class="flag">F</th>
         <th class="description">Narration/Payee</th>
         <th class="amount">Amount</th>
         <th class="cost">Cost</th>
         <th class="price">Price</th>
         <th class="balance">Balance</th>
        </tr>
      </thead>
    ''')

    for row in iterate_html_postings(txn_postings, formatter):
        entry = row.entry

        description = row.description
        if row.links:
            description += render_links(row.links)

        # Render a row.
        write('''
          <tr class="{} {}" title="{}">
            <td class="datecell"><a href="{}">{}</a></td>
            <td class="flag">{}</td>
            <td class="description" colspan="5">{}</td>
          </tr>
        '''.format(row.rowtype, row.extra_class,
                   '{}:{}'.format(entry.meta["filename"], entry.meta["lineno"]),
                   formatter.render_context(entry), entry.date,
                   row.flag, description))

        if render_postings and isinstance(entry, data.Transaction):
            for posting in entry.postings:

                classes = ['Posting']
                if posting.flag == flags.FLAG_WARNING:
                    classes.append('warning')

                write('''
                  <tr class="{}">
                    <td class="datecell"></td>
                    <td class="flag">{}</td>
                    <td class="description">{}</td>
                    <td class="amount num">{}</td>
                    <td class="cost num">{}</td>
                    <td class="price num">{}</td>
                    <td class="balance num">{}</td>
                  </tr>
                '''.format(' '.join(classes),
                           posting.flag or '',
                           formatter.render_account(posting.account),
                           posting.units or '',
                           posting.cost or '',
                           posting.price or '',
                           convert.get_weight(posting)))

    write('</table>')
Esempio n. 12
0
 def test_weight_with_only_price(self):
     self.assertEqual(
         A("53000.00 USD"),
         convert.get_weight(self._pos(A("100 HOOL"), None,
                                      A("530.00 USD"))))
Esempio n. 13
0
 def test_weight__no_cost(self):
     self.assertEqual(A("100 HOOL"),
                      convert.get_weight(self._pos(A("100 HOOL"), None)))
def internalize(entries, transfer_account,
                accounts_value, accounts_internal,
                accounts_internalize=None):
    """Internalize flows that would be lost because booked against external
    flow accounts. This splits up entries that have accounts both in internal
    flows and external flows. A new set of entries are returned, along with a
    list of entries that were split and replaced by a pair of entries.

    Args:
      entries: A list of directives to process for internalization.
      transfer_account: A string, the name of an account to use for internalizing entries
        which need to be split between internal and external flows. A good default value
        would be an equity account, 'Equity:Internalized' or something like that.
      accounts_value: A set of account name strings, the names of the asset accounts
        included in valuing the portfolio.
      accounts_internal: A set of account name strings, the names of internal flow
        accounts (normally income and expenses) that aren't external flows.
      accounts_internalize: A set of account name strings to trigger explicit
        internalization of transactions with no value account. If a transaction
        is found that has only internal accounts and external accounts, the
        postings whose accounts are in this set of accounts will be
        internalized. This is a method that can be used to pull dividends into
        the portfolio when valuing portfolios without their cash component. See
        docstring and documentation for details. If specified, this set of
        accounts must be a subset of the internal flows accounts.
    Returns:
      A pair of the new list of internalized entries, including all the other entries, and
      a short list of just the original entries that were removed and replaced by pairs of
      entries.
    """
    # Verify that external flow entries only affect balance sheet accounts and
    # not income or expenses accounts (internal flows). We do this because we
    # want to ensure that all income and expenses are incurred against assets
    # that live within the assets group. An example of something we'd like to
    # avoid is an external flow paying for fees incurred within the account that
    # should diminish the returns of the related accounts. To fix this, we split
    # the entry into two entries, one without external flows against an transfer
    # account that we consider an assets account, and just the external flows
    # against this same transfer account.
    assert(isinstance(transfer_account, str)), (
        "Invalid transfer account: {}".format(transfer_account))

    if accounts_internalize and not (accounts_internalize <= accounts_internal):
        raise ValueError(
            "Internalization accounts is not a subset of internal flows accounts.")

    new_entries = []
    replaced_entries = []
    index = 1
    for entry in entries:
        if not isinstance(entry, data.Transaction):
            new_entries.append(entry)
            continue

        # Break up postings into the three categories.
        postings_assets = []
        postings_internal = []
        postings_external = []
        postings_internalize = []
        postings_ignore = []
        for posting in entry.postings:
            if posting.account in accounts_value:
                postings_list = postings_assets
            elif posting.account in accounts_internal:
                postings_list = postings_internal
            else:
                postings_list = postings_external
            postings_list.append(posting)

            if accounts_internalize and posting.account in accounts_internalize:
                postings_internalize.append(posting)

        # Check if the entry is to be internalized and split it up in two
        # entries and replace the entry if that's the case.
        if (postings_internal and postings_external and
            (postings_assets or postings_internalize)):

            replaced_entries.append(entry)

            # We will attach a link to each of the split entries.
            link = LINK_FORMAT.format(index)
            index += 1

            # Calculate the weight of the balance to transfer.
            balance_transfer = inventory.Inventory()
            for posting in postings_external:
                balance_transfer.add_amount(convert.get_weight(posting))

            prototype_entry = entry._replace(flag=flags.FLAG_RETURNS,
                                             links=(entry.links or set()) | set([link]))

            # Create internal flows posting.
            postings_transfer_int = [
                data.Posting(transfer_account, pos.units, pos.cost, None, None, None)
                for pos in balance_transfer.get_positions()]
            new_entries.append(prototype_entry._replace(
                postings=(postings_assets + postings_internal + postings_transfer_int)))

            # Create external flows posting.
            postings_transfer_ext = [
                data.Posting(transfer_account, -pos.units, pos.cost, None, None, None)
                for pos in balance_transfer.get_positions()]
            new_entries.append(prototype_entry._replace(
                postings=(postings_transfer_ext + postings_external)))
        else:
            new_entries.append(entry)

    # The transfer account does not have an Open entry, insert one. (This is
    # just us being pedantic about Beancount requirements, this will not change
    # the returns, but if someone looks at internalized entries it produces a
    # correct set of entries you can load cleanly).
    open_close_map = getters.get_account_open_close(new_entries)
    if transfer_account not in open_close_map:
        open_transfer_entry = data.Open(data.new_metadata("beancount.projects.returns", 0),
                                        new_entries[0].date,
                                        transfer_account, None, None)
        new_entries.insert(0, open_transfer_entry)

    return new_entries, replaced_entries
Esempio n. 15
0
 def test_weight__with_cost(self):
     self.assertEqual(
         A("51400.00 USD"),
         convert.get_weight(
             self._pos(A("100 HOOL"), Cost(D("514.00"), "USD", None,
                                           None))))
Esempio n. 16
0
 def __call__(self, context):
     return convert.get_weight(context.posting)
Esempio n. 17
0
def handle_ambiguous_matches(entry, posting, matches, booking_method):
    """Handle ambiguous matches.

    Args:
      entry: The parent Transaction instance.
      posting: An instance of Posting, the reducing posting which we're
        attempting to match.
      matches: A list of matching Position instances from the ante-inventory.
        Those positions are known to already match the 'posting' spec.
      booking_methods: A mapping of account name to their corresponding booking
        method.
    Returns:
      A pair of
        booked_postings: A list of matched Posting instances, whose 'cost'
          attributes are ensured to be of type Cost.
        errors: A list of errors to be generated.
    """
    assert isinstance(booking_method,
                      Booking), ("Invalid type: {}".format(booking_method))
    assert matches, "Internal error: Invalid call with no matches"

    postings = []
    errors = []
    insufficient = False
    if booking_method is Booking.STRICT:
        # In strict mode, we require at most a single matching posting.
        if len(matches) > 1:
            # If the total requested to reduce matches the sum of all the
            # ambiguous postings, match against all of them.
            sum_matches = sum(p.units.number for p in matches)
            if sum_matches == -posting.units.number:
                postings.extend(
                    posting._replace(units=-match.units, cost=match.cost)
                    for match in matches)
            else:
                errors.append(
                    ReductionError(
                        entry.meta, 'Ambiguous matches for "{}": {}'.format(
                            position.to_string(posting), ', '.join(
                                position.to_string(match_posting)
                                for match_posting in matches)), entry))
        else:
            # Replace the posting's units and cost values.
            match = matches[0]
            sign = -1 if posting.units.number < ZERO else 1
            number = min(abs(match.units.number), abs(posting.units.number))
            match_units = Amount(number * sign, match.units.currency)
            postings.append(
                posting._replace(units=match_units, cost=match.cost))
            insufficient = (match_units.number != posting.units.number)

    elif booking_method in (Booking.FIFO, Booking.LIFO):
        # Each up the positions.
        sign = -1 if posting.units.number < ZERO else 1
        remaining = abs(posting.units.number)
        for match in sorted(matches,
                            key=lambda p: p.cost and p.cost.date,
                            reverse=(booking_method == Booking.LIFO)):
            if remaining <= ZERO:
                break

            # If the inventory somehow ended up with mixed lots, skip this one.
            if match.units.number * sign > ZERO:
                continue

            # Compute the amount of units we can reduce from this leg.
            size = min(abs(match.units.number), remaining)
            postings.append(
                posting._replace(units=Amount(size * sign,
                                              match.units.currency),
                                 cost=match.cost))
            remaining -= size

        # If we couldn't eat up all the requested reduction, return an error.
        insufficient = (remaining > ZERO)

    elif booking_method is Booking.NONE:
        # This never needs to match against any existing positions... we
        # disregard the matches, there's never any error. Note that this never
        # gets called in practice, we want to treat NONE postings as
        # augmentations. Default behaviour is to return them with their original
        # CostSpec, and the augmentation code will handle signaling an error if
        # there is insufficient detail to carry out the conversion to an
        # instance of Cost.
        postings.append(posting)

        # Note that it's an interesting question whether a reduction on an
        # account with NONE method which happens to match a single position
        # ought to be matched against it. We don't allow it for now.

    elif booking_method is Booking.AVERAGE:
        errors.append(
            ReductionError(entry.meta, "AVERAGE method is not supported",
                           entry))

    elif False:  # pylint: disable=using-constant-test
        # DISABLED - This is the code for AVERAGE, which is currently disabled.

        # If there is more than a single match we need to ultimately merge the
        # postings. Also, if the reducing posting provides a specific cost, we
        # need to update the cost basis as well. Both of these cases are carried
        # out by removing all the matches and readding them later on.
        if len(matches) == 1 and (
                not isinstance(posting.cost.number_per, Decimal)
                and not isinstance(posting.cost.number_total, Decimal)):
            # There is no cost. Just reduce the one leg. This should be the
            # normal case if we always merge augmentations and the user lets
            # Beancount deal with the cost.
            match = matches[0]
            sign = -1 if posting.units.number < ZERO else 1
            number = min(abs(match.units.number), abs(posting.units.number))
            match_units = Amount(number * sign, match.units.currency)
            postings.append(
                posting._replace(units=match_units, cost=match.cost))
            insufficient = (match_units.number != posting.units.number)
        else:
            # Merge the matching postings to a single one.
            merged_units = inventory.Inventory()
            merged_cost = inventory.Inventory()
            for match in matches:
                merged_units.add_amount(match.units)
                merged_cost.add_amount(convert.get_weight(match))
            if len(merged_units) != 1 or len(merged_cost) != 1:
                errors.append(
                    ReductionError(
                        entry.meta,
                        'Cannot merge positions in multiple currencies: {}'.
                        format(', '.join(
                            position.to_string(match_posting)
                            for match_posting in matches)), entry))
            else:
                if (isinstance(posting.cost.number_per, Decimal)
                        or isinstance(posting.cost.number_total, Decimal)):
                    errors.append(
                        ReductionError(
                            entry.meta,
                            "Explicit cost reductions aren't supported yet: {}"
                            .format(position.to_string(posting)), entry))
                else:
                    # Insert postings to remove all the matches.
                    postings.extend(
                        posting._replace(units=-match.units,
                                         cost=match.cost,
                                         flag=flags.FLAG_MERGING)
                        for match in matches)
                    units = merged_units[0].units
                    date = matches[0].cost.date  ## FIXME: Select which one,
                    ## oldest or latest.
                    cost_units = merged_cost[0].units
                    cost = Cost(cost_units.number / units.number,
                                cost_units.currency, date, None)

                    # Insert a posting to refill those with a replacement match.
                    postings.append(
                        posting._replace(units=units,
                                         cost=cost,
                                         flag=flags.FLAG_MERGING))

                    # Now, match the reducing request against this lot.
                    postings.append(
                        posting._replace(units=posting.units, cost=cost))
                    insufficient = abs(posting.units.number) > abs(
                        units.number)

    if insufficient:
        errors.append(
            ReductionError(
                entry.meta, 'Not enough lots to reduce "{}": {}'.format(
                    position.to_string(posting), ', '.join(
                        position.to_string(match_posting)
                        for match_posting in matches)), entry))

    return postings, errors
Esempio n. 18
0
def booking_method_AVERAGE(entry, posting, matches):
    """AVERAGE booking method implementation."""
    booked_reductions = []
    booked_matches = []
    errors = [AmbiguousMatchError(entry.meta, "AVERAGE method is not supported", entry)]
    return booked_reductions, booked_matches, errors, False

    # FIXME: Future implementation here.
    # pylint: disable=unreachable
    if False: # pylint: disable=using-constant-test
        # DISABLED - This is the code for AVERAGE, which is currently disabled.

        # If there is more than a single match we need to ultimately merge the
        # postings. Also, if the reducing posting provides a specific cost, we
        # need to update the cost basis as well. Both of these cases are carried
        # out by removing all the matches and readding them later on.
        if len(matches) == 1 and (
                not isinstance(posting.cost.number_per, Decimal) and
                not isinstance(posting.cost.number_total, Decimal)):
            # There is no cost. Just reduce the one leg. This should be the
            # normal case if we always merge augmentations and the user lets
            # Beancount deal with the cost.
            match = matches[0]
            sign = -1 if posting.units.number < ZERO else 1
            number = min(abs(match.units.number), abs(posting.units.number))
            match_units = Amount(number * sign, match.units.currency)
            booked_reductions.append(posting._replace(units=match_units, cost=match.cost))
            insufficient = (match_units.number != posting.units.number)
        else:
            # Merge the matching postings to a single one.
            merged_units = inventory.Inventory()
            merged_cost = inventory.Inventory()
            for match in matches:
                merged_units.add_amount(match.units)
                merged_cost.add_amount(convert.get_weight(match))
            if len(merged_units) != 1 or len(merged_cost) != 1:
                errors.append(
                    AmbiguousMatchError(
                        entry.meta,
                        'Cannot merge positions in multiple currencies: {}'.format(
                            ', '.join(position.to_string(match_posting)
                                      for match_posting in matches)), entry))
            else:
                if (isinstance(posting.cost.number_per, Decimal) or
                    isinstance(posting.cost.number_total, Decimal)):
                    errors.append(
                        AmbiguousMatchError(
                            entry.meta,
                            "Explicit cost reductions aren't supported yet: {}".format(
                                position.to_string(posting)), entry))
                else:
                    # Insert postings to remove all the matches.
                    booked_reductions.extend(
                        posting._replace(units=-match.units, cost=match.cost,
                                         flag=flags.FLAG_MERGING)
                        for match in matches)
                    units = merged_units[0].units
                    date = matches[0].cost.date  ## FIXME: Select which one,
                                                 ## oldest or latest.
                    cost_units = merged_cost[0].units
                    cost = Cost(cost_units.number/units.number, cost_units.currency,
                                date, None)

                    # Insert a posting to refill those with a replacement match.
                    booked_reductions.append(
                        posting._replace(units=units, cost=cost, flag=flags.FLAG_MERGING))

                    # Now, match the reducing request against this lot.
                    booked_reductions.append(
                        posting._replace(units=posting.units, cost=cost))
                    insufficient = abs(posting.units.number) > abs(units.number)
Esempio n. 19
0
 def test_weight__with_cost_missing(self):
     self.assertEqual(
         A("100 HOOL"),
         convert.get_weight(
             self._pos(A("100 HOOL"), Cost(MISSING, "USD", None, None))))
Esempio n. 20
0
def validate_sell_gains(entries, options_map):
    """Check the sum of asset account totals for lots sold with a price on them.

    Args:
      entries: A list of directives.
      unused_options_map: An options map.
    Returns:
      A list of new errors, if any were found.
    """
    errors = []
    acc_types = options.get_account_types(options_map)
    proceed_types = set([
        acc_types.assets, acc_types.liabilities, acc_types.equity,
        acc_types.expenses
    ])

    for entry in entries:
        if not isinstance(entry, data.Transaction):
            continue

        # Find transactions whose lots at cost all have a price.
        postings_at_cost = [
            posting for posting in entry.postings if posting.cost is not None
        ]
        if not postings_at_cost or not all(posting.price is not None
                                           for posting in postings_at_cost):
            continue

        # Accumulate the total expected proceeds and the sum of the asset and
        # expenses legs.
        total_price = inventory.Inventory()
        total_proceeds = inventory.Inventory()
        for posting in entry.postings:
            # If the posting is held at cost, add the priced value to the balance.
            if posting.cost is not None:
                assert posting.price is not None
                price = posting.price
                total_price.add_amount(amount.mul(price,
                                                  -posting.units.number))
            else:
                # Otherwise, use the weight and ignore postings to Income accounts.
                atype = account_types.get_account_type(posting.account)
                if atype in proceed_types:
                    total_proceeds.add_amount(convert.get_weight(posting))

        # Compare inventories, currency by currency.
        dict_price = {
            pos.units.currency: pos.units.number
            for pos in total_price
        }
        dict_proceeds = {
            pos.units.currency: pos.units.number
            for pos in total_proceeds
        }

        tolerances = interpolate.infer_tolerances(entry.postings, options_map)
        invalid = False
        for currency, price_number in dict_price.items():
            # Accept a looser than usual tolerance because rounding occurs
            # differently. Also, it would be difficult for the user to satisfy
            # two sets of constraints manually.
            tolerance = tolerances.get(currency) * EXTRA_TOLERANCE_MULTIPLIER

            proceeds_number = dict_proceeds.pop(currency, ZERO)
            diff = abs(price_number - proceeds_number)
            if diff > tolerance:
                invalid = True
                break

        if invalid or dict_proceeds:
            errors.append(
                SellGainsError(
                    entry.meta,
                    "Invalid price vs. proceeds/gains: {} vs. {}".format(
                        total_price, total_proceeds), entry))

    return entries, errors
Esempio n. 21
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)