def abbreviate_account(acc: str, accounts_map: Dict[str, data.Open]): """Compute an abbreviated version of the account name.""" # Get the root of the account by inspecting the "root: TRUE" attribute up # the accounts tree. racc = acc while racc: racc = account.parent(racc) dopen = accounts_map.get(racc, None) if dopen and dopen.meta.get('root', False): acc = racc break # Remove the account type. acc = account.sans_root(acc) # Remove the two-letter country code if there is one. if re.match(r'[A-Z][A-Z]', acc): acc = account.sans_root(acc) return acc
def add_unrealized_gains(entries, options_map, subaccount=None): """Insert entries for unrealized capital gains. This function inserts entries that represent unrealized gains, at the end of the available history. It returns a new list of entries, with the new gains inserted. It replaces the account type with an entry in an income account. Optionally, it can book the gain in a subaccount of the original and income accounts. Args: entries: A list of data directives. options_map: A dict of options, that confirms to beancount.parser.options. subaccount: A string, and optional the name of a subaccount to create under an account to book the unrealized gain. If this is left to its default value, the gain is booked directly in the same account. Returns: A list of entries, which includes the new unrealized capital gains entries at the end, and a list of errors. The new list of entries is still sorted. """ errors = [] meta = data.new_metadata('<unrealized_gains>', 0) account_types = options.get_account_types(options_map) # Assert the subaccount name is in valid format. if subaccount: validation_account = account.join(account_types.assets, subaccount) if not account.is_valid(validation_account): errors.append( UnrealizedError(meta, "Invalid subaccount name: '{}'".format(subaccount), None)) return entries, errors if not entries: return (entries, errors) # Group positions by (account, cost, cost_currency). price_map = prices.build_price_map(entries) holdings_list = holdings.get_final_holdings(entries, price_map=price_map) # Group positions by (account, cost, cost_currency). holdings_list = holdings.aggregate_holdings_by( holdings_list, lambda h: (h.account, h.currency, h.cost_currency)) # Get the latest prices from the entries. price_map = prices.build_price_map(entries) # Create transactions to account for each position. new_entries = [] latest_date = entries[-1].date for index, holding in enumerate(holdings_list): if (holding.currency == holding.cost_currency or holding.cost_currency is None): continue # Note: since we're only considering positions held at cost, the # transaction that created the position *must* have created at least one # price point for that commodity, so we never expect for a price not to # be available, which is reasonable. if holding.price_number is None: # An entry without a price might indicate that this is a holding # resulting from leaked cost basis. {0ed05c502e63, b/16} if holding.number: errors.append( UnrealizedError(meta, "A valid price for {h.currency}/{h.cost_currency} " "could not be found".format(h=holding), None)) continue # Compute the PnL; if there is no profit or loss, we create a # corresponding entry anyway. pnl = holding.market_value - holding.book_value if holding.number == ZERO: # If the number of units sum to zero, the holdings should have been # zero. errors.append( UnrealizedError( meta, "Number of units of {} in {} in holdings sum to zero " "for account {} and should not".format( holding.currency, holding.cost_currency, holding.account), None)) continue # Compute the name of the accounts and add the requested subaccount name # if requested. asset_account = holding.account income_account = account.join(account_types.income, account.sans_root(holding.account)) if subaccount: asset_account = account.join(asset_account, subaccount) income_account = account.join(income_account, subaccount) # Create a new transaction to account for this difference in gain. gain_loss_str = "gain" if pnl > ZERO else "loss" narration = ("Unrealized {} for {h.number} units of {h.currency} " "(price: {h.price_number:.4f} {h.cost_currency} as of {h.price_date}, " "average cost: {h.cost_number:.4f} {h.cost_currency})").format( gain_loss_str, h=holding) entry = data.Transaction(data.new_metadata(meta["filename"], lineno=1000 + index), latest_date, flags.FLAG_UNREALIZED, None, narration, EMPTY_SET, EMPTY_SET, []) # Book this as income, converting the account name to be the same, but as income. # Note: this is a rather convenient but arbitrary choice--maybe it would be best to # let the user decide to what account to book it, but I don't a nice way to let the # user specify this. # # Note: we never set a price because we don't want these to end up in Conversions. entry.postings.extend([ data.Posting( asset_account, amount.Amount(pnl, holding.cost_currency), None, None, None, None), data.Posting( income_account, amount.Amount(-pnl, holding.cost_currency), None, None, None, None) ]) new_entries.append(entry) # Ensure that the accounts we're going to use to book the postings exist, by # creating open entries for those that we generated that weren't already # existing accounts. new_accounts = {posting.account for entry in new_entries for posting in entry.postings} open_entries = getters.get_account_open_close(entries) new_open_entries = [] for account_ in sorted(new_accounts): if account_ not in open_entries: meta = data.new_metadata(meta["filename"], index) open_entry = data.Open(meta, latest_date, account_, None, None) new_open_entries.append(open_entry) return (entries + new_open_entries + new_entries, errors)
def test_sans_root(self): self.assertEqual("Toys:Computer", account.sans_root("Expenses:Toys:Computer")) self.assertEqual("US:BofA:Checking", account.sans_root("Assets:US:BofA:Checking")) self.assertEqual("", account.sans_root("Assets"))
def option(self, filename, lineno, key, value): """Process an option directive. Args: filename: current filename. lineno: current line number. key: option's key (str) value: option's value """ if key not in self.options: meta = new_metadata(filename, lineno) self.errors.append( ParserError(meta, "Invalid option: '{}'".format(key), None)) elif key in options.READ_ONLY_OPTIONS: meta = new_metadata(filename, lineno) self.errors.append( ParserError(meta, "Option '{}' may not be set".format(key), None)) else: option_descriptor = options.OPTIONS[key] # Issue a warning if the option is deprecated. if option_descriptor.deprecated: meta = new_metadata(filename, lineno) self.errors.append( DeprecatedError(meta, option_descriptor.deprecated, None)) # Convert the value, if necessary. if option_descriptor.converter: try: value = option_descriptor.converter(value) except ValueError as exc: meta = new_metadata(filename, lineno) self.errors.append( ParserError( meta, "Error for option '{}': {}".format(key, exc), None)) return option = self.options[key] if isinstance(option, list): # Append to a list of values. option.append(value) elif isinstance(option, dict): # Set to a dict of values. if not (isinstance(value, tuple) and len(value) == 2): self.errors.append( ParserError( meta, "Error for option '{}': {}".format(key, value), None)) return dict_key, dict_value = value option[dict_key] = dict_value elif isinstance(option, bool): # Convert to a boolean. if not isinstance(value, bool): value = (value.lower() in {'true', 'on'}) or (value == '1') self.options[key] = value else: # Fix up account_rounding to be a subaccount if the user specified a # full account name. This is intended to ease transition in the # change of semantics that occurred on 2015-09-05, whereby the value # of this option became defined as a subaccount of Equity instead of # a full account name. See Issue #67. # This should eventually be deprecated, say, in a year (after Sep 2016). if key == 'account_rounding': root = account.root(1, value) if root in (self.options['name_{}'.format(name)] for name in [ 'assets', 'liabilities', 'equity', 'income', 'expenses' ]): self.errors.append( ParserError( self.get_lexer_location(), "'account_rounding' option should now refer to " "a subaccount.", None)) value = account.sans_root(value) # Set the value. self.options[key] = value # Refresh the list of valid account regexps as we go along. if key.startswith('name_'): # Update the set of valid account types. self.account_regexp = valid_account_regexp(self.options)
def spread(entry, config_obj): # computes the speaded version of a transaction. # i.e. distributing yearly PnL reports over the months # make emptly list of entries & errors entries = [] errors = [] # get info from incoming entry asset_posting = get_asset(entry) income_posting = get_income(entry) claim_account = config_obj['liability_acc_base'] + \ acc.sans_root(income_posting.account) units = asset_posting.units value = units.number currency = units.currency amount = Amount(value, currency) # make claim posting for final entry claim_posting = data.Posting(account=claim_account, units=-amount, cost=None, price=None, flag=None, meta=None) # make final entry trans_orig = data.Transaction(meta=entry.meta, date=entry.date, flag=entry.flag, payee=entry.payee, narration=entry.narration, tags=entry.tags, links=entry.links, postings=[claim_posting, asset_posting]) entries.append(trans_orig) # make spread-out transactions # number of divisions n_divides = int(entry.meta['p_spreading_times']) # list of dates dates = pd.date_range(entry.meta['p_spreading_start'], periods=n_divides, freq=entry.meta['p_spreading_frequency']) dates = [x.date() for x in list(dates)] # list of values. pay attentino to decimal rounding splits = [round(value / n_divides, 2) for i in range(n_divides - 1)] splits.append(value - sum(splits)) # make transactions for date, split in zip(dates, splits): amount = Amount(split, currency) # income leg pnl = data.Posting(account=income_posting.account, units=-amount, cost=None, price=None, flag=None, meta=None) claim = data.Posting(account=claim_account, units=amount, cost=None, price=None, flag=None, meta=None) dropkeys = [ 'p_spreading_times', 'p_spreading_start', 'p_spreading_frequency' ] meta = { key: val for key, val in entry.meta.items() if key not in dropkeys } meta.update({ 'p_spreading': f"split {value} into {n_divides} chunks, {entry.meta['p_spreading_frequency']}" }) trans = data.Transaction(meta=meta, date=date, flag='*', payee=entry.payee, narration=entry.narration, tags=entry.tags, links=entry.links, postings=[pnl, claim]) entries.append(trans) return entries, errors
def add_unrealized_gains_at_date(entries, unrealized_entries, income_account_type, price_map, date, meta, subaccount): """Insert/remove entries for unrealized capital gains This function takes a list of entries and a date and creates a set of unrealized gains transactions, negating previous unrealized gains transactions within the same account. Args: entries: A list of data directives. unrealized_entries: A list of previously generated unrealized transactions. income_account_type: The income account type. price_map: A price map returned by prices.build_price_map. date: The effective date to generate the unrealized transactions for. meta: meta. subaccount: A string, and optional the name of a subaccount to create under an account to book the unrealized gain. If this is left to its default value, the gain is booked directly in the same account. Returns: A list of newly created unrealized transactions and a list of errors. """ errors = [] entries_truncated = summarize.truncate(entries, date + ONEDAY) holdings_list = holdings.get_final_holdings(entries_truncated, price_map=price_map, date=date) # Group positions by (account, cost, cost_currency). holdings_list = holdings.aggregate_holdings_by( holdings_list, lambda h: (h.account, h.currency, h.cost_currency)) holdings_with_currencies = set() # Create transactions to account for each position. new_entries = [] for index, holding in enumerate(holdings_list): if (holding.currency == holding.cost_currency or holding.cost_currency is None): continue # Note: since we're only considering positions held at cost, the # transaction that created the position *must* have created at least one # price point for that commodity, so we never expect for a price not to # be available, which is reasonable. if holding.price_number is None: # An entry without a price might indicate that this is a holding # resulting from leaked cost basis. {0ed05c502e63, b/16} if holding.number: errors.append( UnrealizedError( meta, "A valid price for {h.currency}/{h.cost_currency} " "could not be found".format(h=holding), None)) continue # Compute the PnL; if there is no profit or loss, we create a # corresponding entry anyway. pnl = holding.market_value - holding.book_value if holding.number == ZERO: # If the number of units sum to zero, the holdings should have been # zero. errors.append( UnrealizedError( meta, "Number of units of {} in {} in holdings sum to zero " "for account {} and should not".format( holding.currency, holding.cost_currency, holding.account), None)) continue # Compute the name of the accounts and add the requested subaccount name # if requested. asset_account = holding.account income_account = account.join(income_account_type, account.sans_root(holding.account)) if subaccount: asset_account = account.join(asset_account, subaccount) income_account = account.join(income_account, subaccount) holdings_with_currencies.add( (holding.account, holding.cost_currency, holding.currency)) # Find the previous unrealized gain entry to negate and decide if we # should create a new posting. latest_unrealized_entry = find_previous_unrealized_transaction( unrealized_entries, asset_account, holding.cost_currency, holding.currency) # Don't create a new transaction if our last one hasn't changed. if (latest_unrealized_entry and pnl == latest_unrealized_entry.postings[0].units.number): continue # Don't bother creating a blank unrealized transaction if none existed if pnl == ZERO and not latest_unrealized_entry: continue relative_pnl = pnl if latest_unrealized_entry: relative_pnl = pnl - latest_unrealized_entry.postings[ 0].units.number # Create a new transaction to account for this difference in gain. gain_loss_str = "gain" if relative_pnl > ZERO else "loss" narration = ( "Unrealized {} for {h.number} units of {h.currency} " "(price: {h.price_number:.4f} {h.cost_currency} as of {h.price_date}, " "average cost: {h.cost_number:.4f} {h.cost_currency})").format( gain_loss_str, h=holding) entry = data.Transaction( data.new_metadata(meta["filename"], lineno=1000 + index, kvlist={'prev_currency': holding.currency}), date, flags.FLAG_UNREALIZED, None, narration, set(), set(), []) # Book this as income, converting the account name to be the same, but as income. # Note: this is a rather convenient but arbitraty choice--maybe it would be best to # let the user decide to what account to book it, but I don't a nice way to let the # user specify this. # # Note: we never set a price because we don't want these to end up in Conversions. entry.postings.extend([ data.Posting(asset_account, amount.Amount(pnl, holding.cost_currency), None, None, None, None), data.Posting(income_account, amount.Amount(-pnl, holding.cost_currency), None, None, None, None) ]) if latest_unrealized_entry: for posting in latest_unrealized_entry.postings[:2]: entry.postings.append( data.Posting(posting.account, -posting.units, None, None, None, None)) new_entries.append(entry) return new_entries, holdings_with_currencies, errors
def add_unrealized_gains_at_date(entries, unrealized_entries, income_account_type, price_map, date, meta, subaccount): """Insert/remove entries for unrealized capital gains This function takes a list of entries and a date and creates a set of unrealized gains transactions, negating previous unrealized gains transactions within the same account. Args: entries: A list of data directives. unrealized_entries: A list of previously generated unrealized transactions. income_account_type: The income account type. price_map: A price map returned by prices.build_price_map. date: The effective date to generate the unrealized transactions for. meta: meta. subaccount: A string, and optional the name of a subaccount to create under an account to book the unrealized gain. If this is left to its default value, the gain is booked directly in the same account. Returns: A list of newly created unrealized transactions and a list of errors. """ errors = [] entries_truncated = summarize.truncate(entries, date + ONEDAY) holdings_list = holdings.get_final_holdings(entries_truncated, price_map=price_map, date=date) # Group positions by (account, cost, cost_currency). holdings_list = holdings.aggregate_holdings_by( holdings_list, lambda h: (h.account, h.currency, h.cost_currency)) holdings_with_currencies = set() # Create transactions to account for each position. new_entries = [] for index, holding in enumerate(holdings_list): if (holding.currency == holding.cost_currency or holding.cost_currency is None): continue # Note: since we're only considering positions held at cost, the # transaction that created the position *must* have created at least one # price point for that commodity, so we never expect for a price not to # be available, which is reasonable. if holding.price_number is None: # An entry without a price might indicate that this is a holding # resulting from leaked cost basis. {0ed05c502e63, b/16} if holding.number: errors.append( UnrealizedError(meta, "A valid price for {h.currency}/{h.cost_currency} " "could not be found".format(h=holding), None)) continue # Compute the PnL; if there is no profit or loss, we create a # corresponding entry anyway. pnl = holding.market_value - holding.book_value if holding.number == ZERO: # If the number of units sum to zero, the holdings should have been # zero. errors.append( UnrealizedError( meta, "Number of units of {} in {} in holdings sum to zero " "for account {} and should not".format( holding.currency, holding.cost_currency, holding.account), None)) continue # Compute the name of the accounts and add the requested subaccount name # if requested. asset_account = holding.account income_account = account.join(income_account_type, account.sans_root(holding.account)) if subaccount: asset_account = account.join(asset_account, subaccount) income_account = account.join(income_account, subaccount) holdings_with_currencies.add((holding.account, holding.cost_currency, holding.currency)) # Find the previous unrealized gain entry to negate and decide if we # should create a new posting. latest_unrealized_entry = find_previous_unrealized_transaction(unrealized_entries, asset_account, holding.cost_currency, holding.currency) # Don't create a new transaction if our last one hasn't changed. if (latest_unrealized_entry and pnl == latest_unrealized_entry.postings[0].units.number): continue # Don't bother creating a blank unrealized transaction if none existed if pnl == ZERO and not latest_unrealized_entry: continue relative_pnl = pnl if latest_unrealized_entry: relative_pnl = pnl - latest_unrealized_entry.postings[0].units.number # Create a new transaction to account for this difference in gain. gain_loss_str = "gain" if relative_pnl > ZERO else "loss" narration = ("Unrealized {} for {h.number} units of {h.currency} " "(price: {h.price_number:.4f} {h.cost_currency} as of {h.price_date}, " "average cost: {h.cost_number:.4f} {h.cost_currency})").format( gain_loss_str, h=holding) entry = data.Transaction(data.new_metadata(meta["filename"], lineno=1000 + index, kvlist={'prev_currency': holding.currency}), date, flags.FLAG_UNREALIZED, None, narration, set(), set(), []) # Book this as income, converting the account name to be the same, but as income. # Note: this is a rather convenient but arbitraty choice--maybe it would be best to # let the user decide to what account to book it, but I don't a nice way to let the # user specify this. # # Note: we never set a price because we don't want these to end up in Conversions. entry.postings.extend([ data.Posting( asset_account, amount.Amount(pnl, holding.cost_currency), None, None, None, None), data.Posting( income_account, amount.Amount(-pnl, holding.cost_currency), None, None, None, None) ]) if latest_unrealized_entry: for posting in latest_unrealized_entry.postings[:2]: entry.postings.append( data.Posting( posting.account, -posting.units, None, None, None, None)) new_entries.append(entry) return new_entries, holdings_with_currencies, errors