def get_assets_holdings(entries, options_map, currency=None): """Return holdings for all assets and liabilities. Args: entries: A list of directives. options_map: A dict of parsed options. currency: If specified, a string, the target currency to convert all holding values to. Returns: A list of Holding instances and a price-map. """ # Compute a price map, to perform conversions. price_map = prices.build_price_map(entries) # Get the list of holdings. account_types = options.get_account_types(options_map) holdings_list = holdings.get_final_holdings( entries, (account_types.assets, account_types.liabilities), price_map) # Convert holdings to a unified currency. if currency: holdings_list = holdings.convert_to_currency(price_map, currency, holdings_list) return holdings_list, price_map
def test_get_final_holdings_with_prices(self, entries, _, __): """ 2013-01-01 open Assets:Account1 2013-01-01 open Assets:Account2 2013-01-01 open Assets:Account3 2013-01-01 open Assets:Cash 2013-01-01 open Equity:Unknown 2013-04-05 * Equity:Unknown Assets:Cash 50000 USD 2013-04-01 * Assets:Account1 15 HOOL {518.73 USD} Assets:Cash 2013-06-01 price HOOL 578.02 USD """ price_map = prices.build_price_map(entries) holdings_list = holdings.get_final_holdings(entries, ('Assets', 'Liabilities'), price_map) holdings_list = sorted(map(tuple, holdings_list)) expected_values = [ ('Assets:Account1', D('15'), 'HOOL', D('518.73'), 'USD', D('7780.95'), D('8670.30'), D('578.02'), datetime.date(2013, 6, 1)), ('Assets:Cash', D('42219.05'), 'USD', None, 'USD', D('42219.05'), D('42219.05'), None, None), # Notice no Equity account. ] self.assertEqual(expected_values, holdings_list)
def holdings(self, aggregation_key=None): holdings_list = holdings.get_final_holdings( self.entries, (self.account_types.assets, self.account_types.liabilities), self.price_map) if aggregation_key: holdings_list = holdings.aggregate_holdings_by( holdings_list, operator.attrgetter(aggregation_key)) return holdings_list
def test_get_final_holdings__check_no_aggregates(self, entries, _, __): """ plugin "beancount.plugins.unrealized" "Unrealized" 2013-01-01 open Assets:Investment HOOL 2013-01-01 open Assets:Cash USD 2013-04-01 * Assets:Investment 15 HOOL {518.73 USD} Assets:Cash 2013-06-01 price HOOL 600.00 USD """ holdings_list = holdings.get_final_holdings(entries) # Ensure that there is no Unrealized balance or sub-account. self.assertEqual({'Assets:Cash', 'Assets:Investment'}, set(holding.account for holding in holdings_list))
def test_get_final_holdings__zero_position(self, entries, _, __): """ 1970-01-01 open Assets:Stocks:NYA 1970-01-01 open Expenses:Financial:Commissions 1970-01-01 open Assets:Current 1970-01-01 open Income:Dividends:NYA 2012-07-02 ! "I received 1 new share in dividend, without paying" Assets:Stocks:NYA 1 NYA {0 EUR} Income:Dividends:NYA -0 EUR 2014-11-13 balance Assets:Stocks:NYA 1 NYA """ price_map = prices.build_price_map(entries) holdings_list = holdings.get_final_holdings(entries, ('Assets', 'Liabilities'), price_map) self.assertEqual(1, len(holdings_list)) self.assertEqual('EUR', holdings_list[0].cost_currency)
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 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 test_get_final_holdings(self, entries, _, __): """ 2013-01-01 open Assets:Account1 2013-01-01 open Assets:Account2 2013-01-01 open Assets:Account3 2013-01-01 open Assets:Cash 2013-01-01 open Liabilities:Loan 2013-01-01 open Equity:Unknown 2013-04-05 * Equity:Unknown Assets:Cash 50000 USD 2013-04-01 * Assets:Account1 15 HOOL {518.73 USD} Assets:Cash 2013-04-02 * Assets:Account1 10 HOOL {523.46 USD} Assets:Cash 2013-04-03 * Assets:Account1 -4 HOOL {518.73 USD} Assets:Cash 2013-04-02 * Assets:Account2 20 ITOT {85.195 USD} Assets:Cash 2013-04-03 * Assets:Account3 50 HOOL {540.00 USD} @ 560.00 USD Assets:Cash 2013-04-10 * Assets:Cash 5111 USD Liabilities:Loan """ holdings_list = holdings.get_final_holdings(entries) holdings_list = sorted(map(tuple, holdings_list)) expected_values = [ ('Assets:Account1', D('10'), 'HOOL', D('523.46'), 'USD', D('5234.60'), None, None, None), ('Assets:Account1', D('11'), 'HOOL', D('518.73'), 'USD', D('5706.03'), None, None, None), ('Assets:Account2', D('20'), 'ITOT', D('85.195'), 'USD', D('1703.900'), None, None, None), ('Assets:Account3', D('50'), 'HOOL', D('540.00'), 'USD', D('27000.00'), None, None, None), ('Assets:Cash', D('15466.470'), 'USD', None, 'USD', D('15466.470'), D('15466.470'), None, None), ('Equity:Unknown', D('-50000'), 'USD', None, 'USD', D('-50000'), D('-50000'), None, None), ('Liabilities:Loan', D('-5111'), 'USD', None, 'USD', D('-5111'), D('-5111'), None, None), ] self.assertEqual(expected_values, holdings_list) # Try with some account type restrictions. holdings_list = holdings.get_final_holdings(entries, ('Assets', 'Liabilities')) holdings_list = sorted(map(tuple, holdings_list)) expected_values = [holding for holding in expected_values if (holding[0].startswith('Assets') or holding[0].startswith('Liabilities'))] self.assertEqual(expected_values, holdings_list) # Try with some account type restrictions. holdings_list = holdings.get_final_holdings(entries, ('Assets',)) holdings_list = sorted(map(tuple, holdings_list)) expected_values = [holding for holding in expected_values if holding[0].startswith('Assets')] self.assertEqual(expected_values, holdings_list)
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