def book_price_conversions_plugin(entries, options_map, config): """Plugin that rewrites transactions to insert cost basis according to a booking method. See book_price_conversions() for details. Args: entries: A list of entry instances. options_map: A dict of options parsed from the file. config: A string, in "<ACCOUNT1>,<ACCOUNT2>" format. Returns: A tuple of entries: A list of new, modified entries. errors: A list of errors generated by this plugin. """ # The expected configuration is two account names, separated by whitespace. errors = [] try: assets_account, income_account = re.split(r'[,; \t]', config) if not account.is_valid(assets_account) or not account.is_valid( income_account): raise ValueError("Invalid account string") except ValueError as exc: errors.append( ConfigError( None, ('Invalid configuration: "{}": {}, skipping booking').format( config, exc), None)) return entries, errors new_entries, errors, _ = book_price_conversions(entries, assets_account, income_account) return new_entries, errors
def __init__(self, **kwds): """Pull 'filing' and 'prefix' from kwds.""" self.filing_account = kwds.pop('filing', None) assert account.is_valid(self.filing_account) self.prefix = kwds.pop('prefix', None) assert account.is_valid(self.filing_account) super().__init__(**kwds)
def __init__( self, *args, currency=None, reimbursement_account=None, discount_account=None, prefix=None, tags=set(), debug=False, **kwargs): ''' Available keyword arguments: reimbursement_account the income account to use for reimbursements and filing (unless filing is passed in as a keyword argument). discount_account the income account to use for discounts. currency the currency for all transactions (defaults to "USD"). tags a set of tags to add to every transaction. prefix the filename prefix to use when beancount-file moves files (defaults to "UHC"). debug if True, every row will be printed to stdout. Additional keyword arguments for IdentifyMixin and FilingMixin are permitted. Most significantly: matchers a list of 2-tuples, where the first item is one of "mime", "filename", or "content"; and the second is a regular expression which, if matched, identifies a file as one for which this importer should be used. ''' self.reimbursement_account = reimbursement_account \ or self.reimbursement_account self.discount_account = discount_account or self.discount_account self.currency = currency or self.currency self.tags.update(tags) if debug is not None: self.debug = debug assert beancount_account.is_valid(self.reimbursement_account),\ "{} is not a valid account".format(self.reimbursement_account) assert beancount_account.is_valid(self.discount_account),\ "{} is not a valid account".format(self.discount_account) super().__init__( *args, prefix=prefix or self.prefix, filing=kwargs.get('filing', None) or self.reimbursement_account, row_fields=row_fields, row_class=Row, **kwargs )
def __init__( self, *args, account=None, currency=None, prefix=None, tags=set(), debug=False, invert_amounts=None, **kwargs): ''' Available keyword arguments: account the account to use for every transaction. currency the account currency (defaults to "USD"). tags a set of tags to add to every transaction. prefix the filename prefix to use when beancount-file moves files. debug if True, every row will be printed to stdout. Additional keyword arguments for CategorizerMixin, IdentifyMixin and FilingMixin are permitted. Most significantly: categorizers a list of callables, taking one argument, the csv row (as a Row namedtuple object). The callable may return a CategorizerResult, which will set fields on the transaction; or a string being the name of an account against which to offset the entire amount of the transaction; or None, in which case it will be ignored. If more than one categorizer returns a non-None result, the first result will be used and the transaction will be flagged. matchers a list of 2-tuples, where the first item is one of "mime", "filename", or "content"; and the second is a regular expression which, if matched, identifies a file as one for which this importer should be used. ''' assert self.row_class is not None,\ "The row_class property must be defined." self.account = account or self.account self.currency = currency or self.currency self.tags.update(tags) if invert_amounts is not None: self.invert_amounts = invert_amounts if debug is not None: self.debug = debug assert beancount_account.is_valid(self.account),\ "{} is not a valid account".format(self.account) super().__init__( *args, prefix=prefix or self.prefix, filing=self.account, row_fields=self.row_fields, row_class=self.row_class, **kwargs )
def C(pattern, account_name, field_name='description', row_must_have_field=True, **kwargs): ''' Make a categorizer that returns the given account when the given regular expression is matched on the row's $field_name field (defaults to description). All CategorizerResult fields except account are valid keyword arguments. ''' assert 'account' not in kwargs,\ "'account' is not permitted as a keyword argument" assert account.is_valid(account_name),\ "{} is not a valid account".format(account_name) def f(row): row = row._asdict() if row_must_have_field: assert field_name in row,\ f'Row used for categorizer must have a {field_name} field' if re.search(pattern, row.get(field_name, ''), re.IGNORECASE) is not None: return CategorizerResult(account=account_name, **kwargs) return None return f
def get_account(self, row): result = self.fees_categorizer(row) if result is not None: if not isinstance(result, CategorizerResult): result = CategorizerResult(account=result) assert account.is_valid(result.account),\ "{} is not a valid account".format(result.account) return result.account return self.account
def __init__(self, **kwds): """Pull 'filing' and 'prefix' from kwds. Args: filing: The name of the account to file to. prefix: The name of the institution prefix to insert. """ self.filing_account = kwds.pop('filing', None) assert account.is_valid(self.filing_account) self.prefix = kwds.pop('prefix', None) super().__init__(**kwds)
def get_categorized_postings(self, row): postings = [ data.Posting( account=self.get_account(row), units=self.get_amount(row), cost=None, price=self.get_price(row), flag=None, meta={}), ] final_result = None for result in [c(row) for c in self.categorizers]: if result is None: continue if final_result is not None: # Uh oh, more than one thing matched final_result = final_result._replace(flag=flags.FLAG_WARNING) break if not isinstance(result, CategorizerResult): result = CategorizerResult(account=result) assert account.is_valid(result.account),\ "{} is not a valid account".format(result.account) final_result = result postings.append(data.Posting( account=result.account, units=-self.get_amount(row), cost=None, price=self.get_price(row), flag=None, meta={})) return postings, final_result
def fill_account(entries, unused_options_map, insert_account): """Insert an posting with a default account when there is only a single posting. Args: entries: A list of directives. unused_options_map: A parser options dict. insert_account: A string, the name of the account. Returns: A list of entries, possibly with more Price entries than before, and a list of errors. """ if not account.is_valid(insert_account): return entries, [ FillAccountError( data.new_metadata('<fill_account>', 0), "Invalid account name: '{}'".format(insert_account), None) ] new_entries = [] for entry in entries: if isinstance(entry, data.Transaction) and len(entry.postings) == 1: inv = inventory.Inventory() for posting in entry.postings: if posting.cost is None: inv.add_amount(posting.units) else: inv.add_amount(convert.get_cost(posting)) inv.reduce(convert.get_units) new_postings = list(entry.postings) for pos in inv: new_postings.append( data.Posting(insert_account, -pos.units, None, None, None, None)) entry = entry._replace(postings=new_postings) new_entries.append(entry) return new_entries, []
def insert_currency_trading_postings(entries, options_map, config): """Insert currency trading postings. Args: entries: A list of directives. unused_options_map: An options map. config: The base account name for currency trading accounts. Returns: A list of new errors, if any were found. """ base_account = config.strip() if not account.is_valid(base_account): base_account = DEFAULT_BASE_ACCOUNT errors = [] new_entries = [] new_accounts = set() for entry in entries: if isinstance(entry, Transaction): curmap, has_price = group_postings_by_weight_currency(entry) if has_price and len(curmap) > 1: new_postings = get_neutralizing_postings( curmap, base_account, new_accounts) entry = entry._replace(postings=new_postings) if META_PROCESSED: entry.meta[META_PROCESSED] = True new_entries.append(entry) earliest_date = entries[0].date open_entries = [ data.Open(data.new_metadata('<currency_accounts>', index), earliest_date, acc, None, None) for index, acc in enumerate(sorted(new_accounts)) ] return open_entries + new_entries, errors
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 auto_depreciation(entries, options_map, config=None): """Depreciate fixed assets automatically. Please refer to `Beancount Scripting & Plugins <http://furius.ca/beancount/doc/scripting>`_ for more details. Parameters ---------- entries A list of entry instance. options_map A dict of options parsed from the file. config : str A string of plugin configuration. Returns ------- entries A list of processed entries. errors Error information. """ errors = [] DEFAULT_ASSETS_ACCOUNT = 'Assets:Wealth:Fixed-Assets' DEFAULT_EXPENSES_ACCOUNT = 'Expenses:Property-Expenses:Depreciation' DEFAULT_METHOD = 'parabola' DEFAULT_RESIDUAL_VALUE = 0.0 try: config_dict = eval(config) except (TypeError, SyntaxError): config_dict = {} try: assets_account = config_dict['assets'] except KeyError: assets_account = DEFAULT_ASSETS_ACCOUNT if not account.is_valid(assets_account): assets_account = DEFAULT_ASSETS_ACCOUNT try: expenses_account = config_dict['expenses'] except KeyError: expenses_account = DEFAULT_EXPENSES_ACCOUNT if not account.is_valid(expenses_account): expenses_account = DEFAULT_EXPENSES_ACCOUNT try: method = config_dict['method'] except KeyError: method = DEFAULT_METHOD depreciation_entries = [] for entry in entries: if isinstance(entry, data.Transaction): for posting in entry.postings: if (posting.meta and 'useful_life' in posting.meta and posting.account == assets_account): cost = posting.cost currency = cost.currency original_value = float(cost.number) try: end_value = float(posting.meta['residual_value']) except KeyError: end_value = DEFAULT_RESIDUAL_VALUE label = cost.label buy_date = cost.date m = re.match(r'([0-9]+)([my])', str.lower(posting.meta['useful_life'])) months_or_years = m.group(2) months = int(m.group(1)) if months_or_years == 'y': months = 12 * months dates_list, current_values, depreciation_values \ = depreciation_list(original_value, end_value, buy_date, months, method) latest_pos = posting for i, date in enumerate(dates_list): pos_sell = _posting_to_sell(latest_pos) pos_buy = _posting_to_buy(latest_pos, date, current_values[i]) pos_expense = _posting_to_expense( latest_pos, expenses_account, depreciation_values[i], currency) latest_pos = pos_buy new_pos = [pos_sell, pos_buy, pos_expense] depreciation_entries.append( _auto_entry(entry, date, label, *new_pos)) new_entries = entries + depreciation_entries new_entries.sort(key=data.entry_sortkey) return new_entries, errors
def test_is_valid(self): self.assertTrue(account.is_valid("Assets:US:RBS:Checking")) self.assertTrue(account.is_valid("Equity:Opening-Balances")) self.assertTrue(account.is_valid("Income:US:ETrade:Dividends-USD")) self.assertTrue(account.is_valid("Assets:US:RBS")) self.assertTrue(account.is_valid("Assets:US")) self.assertFalse(account.is_valid("Assets")) self.assertFalse(account.is_valid("Invalid")) self.assertFalse(account.is_valid("Other")) self.assertFalse(account.is_valid("Assets:US:RBS*Checking")) self.assertFalse(account.is_valid("Assets:US:RBS:Checking&")) self.assertFalse(account.is_valid("Assets:US:RBS:checking")) self.assertFalse(account.is_valid("Assets:us:RBS:checking"))
def make_records(*, transaction_details: TransactionDetailsForAddresses, addresses: list[Address], transactions: list[Transactions], currency: spotbit.CurrencyName) -> str: assert transaction_details assert addresses assert transactions assert currency # FIXME I can't use the beancount api to create a data structure that I can then dump to a beancount file. # Instead I must emit strings then load the strings to test for correctness. # Ref. https://beancount.github.io/docs/beancount_language_syntax.html # FINDOUT How to create a collection of entries and dump it to text file. # - Create accounts # - Create transactions # - Add postings to transactions. # - Dump the account to a file. # Ref. realization.py type = 'Assets' country = '' institution = '' btc_account_name = 'BTC' fiat_account_name = currency.value subaccount_name = '' from beancount.core import account components = [ type.title(), country.title(), institution.title(), btc_account_name, subaccount_name ] components = [c for c in components if c != ''] btc_account = account.join(*components) assert account.is_valid( btc_account), f'Account name is not valid. Got: {btc_account}' # Test: Treat cash as a liability. # TODO(nochiel) Store the exchange rate at each transaction date. components = [ 'liabilities'.title(), 'Cash', fiat_account_name, subaccount_name ] components = [c for c in components if c != ''] fiat_account = account.join(*components) assert account.is_valid( fiat_account), f'Account name is not valid. Got: {fiat_account}' # Loop through on-chain transactions and create transactions and relevant postings for each transaction. def get_earliest_blocktime( transactions: list[Transactions] = transactions) -> datetime: assert transactions result = datetime.now() if transactions[0]: result = datetime.fromtimestamp( transactions[0][0]['status']['block_time']) for transactions_for in transactions: if transactions_for: for transaction in transactions_for: timestamp = datetime.fromtimestamp( transaction['status']['block_time']) result = timestamp if timestamp < result else result return result date_of_account_open = get_earliest_blocktime().date() # Commodity directive ''' 1867-07-01 commodity CAD name: "Canadian Dollar" asset-class: "cash" ''' btc_commodity_directive = ('2008-10-31 commodity BTC\n' ' name: "Bitcoin"\n' ' asset-class: "cryptocurrency"\n') # Account directive # e.g. YYYY-MM-DD open Account [ConstraintCurrency,...] ["BookingMethod"] account_directives = [ f'{date_of_account_open} open {btc_account}\tBTC', f'{date_of_account_open} open {fiat_account}\t{currency.value}', ] transactions_by_hash = { tx['txid']: tx for for_address in transactions for tx in for_address } ''' transaction_details_by_hash = {detail.hash : detail for details_for in transaction_details.values() for detail in details_for} n_inputs = 0 for d in transaction_details_by_hash.values(): if d.is_input: n_inputs += 1 logger.debug(f'Number of input txs: {n_inputs}') logger.debug(f'Number of all txs: {len(transaction_details_by_hash.values())}') ''' # TODO(nochiel) Order transactions by date. For each date record a btc price. # e.g. 2015-04-30 price AAPL 125.15 USD btc_price_directive = '' # Transactions and entries. e.g. ''' 2014-05-05 * "Using my new credit card" Liabilities:CreditCard:CapitalOne -37.45 USD Expenses:Restaurant 2014-02-03 * "Initial deposit" Assets:US:BofA:Checking 100 USD Assets:Cash -100 USD ''' # TODO Should I generate Expense accounts if there is an output address that's re-used? # TODO Create an Asset:BTC and Asset:Cash account # The Asset:Cash is equivalent to Asset:BTC but in USD exchange rates at the time of transaction. # Because BTC is volatile, we should list transaction time. class Payee: def __init__(self, address: str, amount: int): self.address = address self.amount = amount # satoshis Transaction = dict def get_payees(transaction: Transaction, ) -> list[Payee]: assert Transaction result = [] outputs = transaction['vout'] result = [ Payee(address=output['scriptpubkey_address'], amount=output['value']) for output in outputs ] return result assert transaction_details # logger.debug(f'transaction_details: {transaction_details}') transaction_directives = [] for address in addresses: details = transaction_details[address] # Post transactions in chronological order. Esplora gives us reverse-chronological order details.reverse() for detail in details: # Create a beancount transaction for each transaction. # Then add beancount transaction entries for each payee/output that is not one of the user's addresses. ''' E.g. 2014-07-11 * "Sold shares of S&P 500" Assets:ETrade:IVV -10 IVV {183.07 USD} @ 197.90 USD Assets:ETrade:Cash 1979.90 USD Income:ETrade:CapitalGains ''' meta = '' date = detail.timestamp.date() flag = '*' payees = get_payees(transactions_by_hash[detail.hash]) if not detail.is_input: payees_in_descriptor = filter( lambda payee: payee.address in addresses, payees) payees = list(payees_in_descriptor) tags = [] links = [] # Should a payee posting use the output address as a subaccount? # Each payee is a transaction # If not is_input put our receiving transactions first. for payee in payees: transaction_directive = f'{date} * "{payee.address}" "Transaction hash: {detail.hash}"' btc_payee_transaction_directive = f'\t{btc_account}\t{"-" if detail.is_input else ""}{payee.amount * 1e-8 : .8f} BTC' transaction_fiat_amount = detail.twap * payee.amount * 1e-8 if not detail.is_input: btc_payee_transaction_directive += f' {{{detail.twap : .2f} {currency.value} }}' if detail.is_input: btc_payee_transaction_directive += f' @ {detail.twap : .2f} {currency.value}\t' fiat_payee_transaction_directive = ( f'\t{fiat_account}\t{"-" if not detail.is_input else ""}' + f'{transaction_fiat_amount : .2f} {currency.value}\t') payee_transaction_directive = btc_payee_transaction_directive payee_transaction_directive += '\n' payee_transaction_directive += fiat_payee_transaction_directive transaction_directive += '\n' transaction_directive += payee_transaction_directive transaction_directive += '\n' transaction_directives.append(transaction_directive) document = '' document = btc_commodity_directive document += '\n' document += str.join('\n', account_directives) document += '\n\n' document += str.join('\n', transaction_directives) # TODO Validate document from beancount import loader _, errors, _ = loader.load_string(document) if errors: logger.error( f'---{len(errors)} Errors in the generated beancount file---') for error in errors: logger.error(error) return document
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) new_entries = [] # Start at the first month after our first transaction date = date_utils.next_month(entries[0].date) last_month = date_utils.next_month(entries[-1].date) last_holdings_with_currencies = None while date <= last_month: date_entries, holdings_with_currencies, date_errors = add_unrealized_gains_at_date( entries, new_entries, account_types.income, price_map, date, meta, subaccount) new_entries.extend(date_entries) errors.extend(date_errors) if last_holdings_with_currencies: for account_, cost_currency, currency in last_holdings_with_currencies - holdings_with_currencies: # Create a negation transaction specifically to mark that all gains have been realized if subaccount: account_ = account.join(account_, subaccount) latest_unrealized_entry = find_previous_unrealized_transaction( new_entries, account_, cost_currency, currency) if not latest_unrealized_entry: continue entry = data.Transaction( data.new_metadata(meta["filename"], lineno=999, kvlist={'prev_currency': currency}), date, flags.FLAG_UNREALIZED, None, 'Clear unrealized gains/losses of {}'.format(currency), set(), set(), []) # Negate the previous transaction because of unrealized gains are now 0 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) last_holdings_with_currencies = holdings_with_currencies date = date_utils.next_month(date) # 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 index, account_ in enumerate(sorted(new_accounts)): if account_ not in open_entries: meta = data.new_metadata(meta["filename"], index) open_entry = data.Open(meta, new_entries[0].date, account_, None, None) new_open_entries.append(open_entry) return (entries + new_open_entries + new_entries, errors)
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) new_entries = [] # Start at the first month after our first transaction date = date_utils.next_month(entries[0].date) last_month = date_utils.next_month(entries[-1].date) last_holdings_with_currencies = None while date <= last_month: date_entries, holdings_with_currencies, date_errors = add_unrealized_gains_at_date( entries, new_entries, account_types.income, price_map, date, meta, subaccount) new_entries.extend(date_entries) errors.extend(date_errors) if last_holdings_with_currencies: for account_, cost_currency, currency in last_holdings_with_currencies - holdings_with_currencies: # Create a negation transaction specifically to mark that all gains have been realized if subaccount: account_ = account.join(account_, subaccount) latest_unrealized_entry = find_previous_unrealized_transaction(new_entries, account_, cost_currency, currency) if not latest_unrealized_entry: continue entry = data.Transaction(data.new_metadata(meta["filename"], lineno=999, kvlist={'prev_currency': currency}), date, flags.FLAG_UNREALIZED, None, 'Clear unrealized gains/losses of {}'.format(currency), set(), set(), []) # Negate the previous transaction because of unrealized gains are now 0 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) last_holdings_with_currencies = holdings_with_currencies date = date_utils.next_month(date) # 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 index, account_ in enumerate(sorted(new_accounts)): if account_ not in open_entries: meta = data.new_metadata(meta["filename"], index) open_entry = data.Open(meta, new_entries[0].date, account_, None, None) new_open_entries.append(open_entry) return (entries + new_open_entries + new_entries, errors)