Exemplo n.º 1
0
def handle_ambiguous_matches(entry, posting, matches, method):
    """Handle ambiguous matches by dispatching to a particular method.

    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.
      methods: A mapping of account name to their corresponding booking
        method.
    Returns:
      A pair of
        booked_reductions: 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(method, Booking), ("Invalid type: {}".format(method))
    assert matches, "Internal error: Invalid call with no matches"

    #method = globals()['booking_method_{}'.format(method.name)]
    method = _BOOKING_METHODS[method]
    (booked_reductions, booked_matches, errors,
     insufficient) = method(entry, posting, matches)
    if insufficient:
        errors.append(
            AmbiguousMatchError(
                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 booked_reductions, booked_matches, errors
Exemplo n.º 2
0
def booking_method_STRICT(entry, posting, matches):
    """Strict booking method.

    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.
    Returns:
      A triple of
        booked_reductions: A list of matched Posting instances, whose 'cost'
          attributes are ensured to be of type Cost.
        errors: A list of errors to be generated.
        insufficient: A boolean, true if we could not find enough matches
          to fulfill the reduction.
    """
    booked_reductions = []
    booked_matches = []
    errors = []
    insufficient = False
    # 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:
            booked_reductions.extend(
                posting._replace(units=-match.units, cost=match.cost)
                for match in matches)
        else:
            errors.append(
                AmbiguousMatchError(
                    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)
        booked_reductions.append(
            posting._replace(units=match_units, cost=match.cost))
        booked_matches.append(match)
        insufficient = (match_units.number != posting.units.number)

    return booked_reductions, booked_matches, errors, insufficient
Exemplo n.º 3
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
Exemplo n.º 4
0
    def Posting(self, posting, entry, oss):
        flag = '{} '.format(posting.flag) if posting.flag else ''
        assert posting.account is not None

        flag_posting = '{:}{:62}'.format(flag, posting.account)

        pos_str = (position.to_string(posting, self.dformat, detail=False)
                   if isinstance(posting.units, Amount) else '')

        if posting.price is not None:
            price_str = '@ {}'.format(posting.price.to_string(
                self.dformat_max))
        else:
            # Figure out if we need to insert a price on a posting held at cost.
            # See https://groups.google.com/d/msg/ledger-cli/35hA0Dvhom0/WX8gY_5kHy0J
            (postings_simple, postings_at_price,
             postings_at_cost) = postings_by_type(entry)

            cost = posting.cost
            if postings_at_price and postings_at_cost and cost:
                price_str = '@ {}'.format(
                    amount.Amount(cost.number,
                                  cost.currency).to_string(self.dformat))
            else:
                price_str = ''

        posting_str = '  {:64} {} {}'.format(flag_posting,
                                             quote_currency(pos_str),
                                             quote_currency(price_str))
        oss.write(posting_str.rstrip())

        oss.write('\n')
Exemplo n.º 5
0
def _serialise_posting(posting):
    """Serialise a posting."""
    if isinstance(posting.units, Amount):
        position_str = position.to_string(posting)
    else:
        position_str = ""

    if posting.price is not None:
        position_str += " @ {}".format(posting.price.to_string())
    return {"account": posting.account, "amount": position_str}
Exemplo n.º 6
0
def _serialise_posting(posting):
    """Serialise a posting."""
    if isinstance(posting.units, Amount):
        position_str = position.to_string(posting)
    else:
        position_str = ''

    if posting.price is not None:
        position_str += ' @ {}'.format(posting.price.to_string())
    return {'account': posting.account, 'amount': position_str}
Exemplo n.º 7
0
def _serialise_posting(posting):
    """Serialise a posting."""
    if isinstance(posting.units, Amount):
        position_str = position.to_string(posting)
    else:
        position_str = ''

    if posting.price is not None:
        position_str += ' @ {}'.format(posting.price.to_string())
    return {
        'account': posting.account,
        'amount': position_str,
    }
Exemplo n.º 8
0
def booking_method_STRICT(entry, posting, matches):
    """Strict booking method. This method fails if there are ambiguous matches.
    """
    booked_reductions = []
    booked_matches = []
    errors = []
    insufficient = False

    # 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:
            booked_reductions.extend(
                posting._replace(units=-match.units, cost=match.cost)
                for match in matches)
        else:
            errors.append(
                AmbiguousMatchError(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)
        booked_reductions.append(posting._replace(units=match_units, cost=match.cost))
        booked_matches.append(match)
        insufficient = (match_units.number != posting.units.number)

    return booked_reductions, booked_matches, errors, insufficient
Exemplo n.º 9
0
    def Posting(self, posting, entry, oss):
        flag = '{} '.format(posting.flag) if posting.flag else ''
        assert posting.account is not None

        flag_posting = '{:}{:62}'.format(flag, posting.account)

        pos_str = (position.to_string(posting, self.dformat, detail=False)
                   if isinstance(posting.units, Amount) else '')
        if pos_str:
            # Convert the cost as a price entry, that's what HLedger appears to want.
            pos_str = pos_str.replace('{', '@ ').replace('}', '')

        price_str = ('@ {}'.format(posting.price.to_string(self.dformat_max))
                     if posting.price is not None else '')

        posting_str = '  {:64} {:>16} {:>16}'.format(flag_posting,
                                                     quote_currency(pos_str),
                                                     quote_currency(price_str))
        oss.write(posting_str.rstrip())

        oss.write('\n')
Exemplo n.º 10
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
Exemplo n.º 11
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)
Exemplo n.º 12
0
def html_entries_table_with_balance(oss, txn_postings, formatter, render_postings=True):
    """Render a list of entries into an HTML table, with a running balance.

    (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="position">Position</th>
         <th class="price">Price</th>
         <th class="cost">Cost</th>
         <th class="change">Change</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="4">{}</td>
            <td class="change num">{}</td>
            <td class="balance num">{}</td>
          </tr>
        '''.format(row.rowtype, row.extra_class,
                   '{}:{}'.format(entry.meta["filename"], entry.meta["lineno"]),
                   formatter.render_context(entry), entry.date,
                   row.flag, description,
                   row.amount_str, row.balance_str))

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

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

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

    write('</table>')
Exemplo n.º 13
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