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
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
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
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')
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}
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}
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, }
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
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')
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
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)
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>')
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