def test_aggregate_holdings_by__commodity(self): # Note: Two different prices on HOOL on purpose. test_holdings = list(itertools.starmap(holdings.Holding, [ ('Assets:Cash', D('101.11'), 'USD', None, None, None, None, None, None), ('Assets:Account1', D('10'), 'HOOL', D('518.73'), 'USD', D('5187.30'), D('5780.20'), D('578.02'), datetime.date(2014, 2, 1)), ('Assets:Account2', D('20'), 'HOOL', D('519.24'), 'USD', D('10384.80'), D('11622.00'), D('581.10'), datetime.date(2014, 2, 15)), ('Assets:Account1', D('10'), 'AAPL', D('593.27'), 'USD', D('5932.70'), D('6000.10'), D('600.01'), datetime.date(2014, 3, 1)), ])) expected_holdings = sorted(itertools.starmap(holdings.Holding, [ ('Assets:Account1', D('10'), 'AAPL', D('593.27'), 'USD', D('5932.70'), D('6000.10'), D('600.01'), datetime.date(2014, 3, 1)), ('Assets', D('30'), 'HOOL', D('519.07'), 'USD', D('15572.10'), D('17402.20'), D('580.0733333333333333333333333'), None), ('Assets:Cash', D('101.11'), 'USD', None, None, None, None, None, None), ])) self.assertEqual(expected_holdings, holdings.aggregate_holdings_by(test_holdings, lambda holding: holding.currency))
def _net_worth_in_intervals(self, interval): interval_tuples = self._interval_tuples(interval, self.entries) interval_totals = [] end_dates = [p[1] for p in interval_tuples] for (begin_date, end_date), holdings_list in \ zip(interval_tuples, holdings_at_dates(self.entries, end_dates, self.price_map, self.options)): totals = {} for currency in self.options['operating_currency']: currency_holdings_list = \ holdings.convert_to_currency(self.price_map, currency, holdings_list) if not currency_holdings_list: continue holdings_ = holdings.aggregate_holdings_by( currency_holdings_list, operator.attrgetter('cost_currency')) holdings_ = [holding for holding in holdings_ if holding.currency and holding.cost_currency] # If after conversion there are no valid holdings, skip the # currency altogether. if holdings_: totals[currency] = holdings_[0].market_value interval_totals.append({ 'date': end_date - datetime.timedelta(1), 'totals': totals }) return interval_totals
def calculate_net_worths(entries, options_map): holdings_list, price_map = holdings.get_assets_holdings( entries, options_map) net_worths = [] for currency in options_map['operating_currency']: # Convert holdings to a unified currency. # # Note: It's entirely possible that the price map does not have all # the necessary rate conversions here. The resulting holdings will # simply have no cost when that is the case. We must handle this # gracefully below. currency_holdings_list = holdings.convert_to_currency( price_map, currency, holdings_list) if not currency_holdings_list: continue aggregated_holdings_list = holdings.aggregate_holdings_by( currency_holdings_list, lambda holding: holding.cost_currency) aggregated_holdings_list = [ holding for holding in aggregated_holdings_list if holding.currency and holding.cost_currency ] # If after conversion there are no valid holdings, skip the currency # altogether. if not aggregated_holdings_list: continue net_worths.append((currency, aggregated_holdings_list[0].market_value)) return net_worths
def test_aggregate_holdings_by__account(self): test_holdings = list(itertools.starmap(holdings.Holding, [ ('Assets:Cash', D('101.11'), 'USD', None, None, None, None, None, None), ('Assets:Account1', D('10'), 'HOOL', D('518.73'), 'USD', D('5187.30'), D('5780.20'), D('578.02'), datetime.date(2014, 2, 1)), ('Assets:Account2', D('20'), 'HOOL', D('519.24'), 'USD', D('10384.80'), D('11622.00'), D('581.10'), datetime.date(2014, 2, 15)), ('Assets:Account1', D('10'), 'AAPL', D('593.27'), 'USD', D('5932.70'), D('6000.10'), D('600.01'), datetime.date(2014, 3, 1)), ])) expected_holdings = sorted(itertools.starmap(holdings.Holding, [ ('Assets:Account1', D('0'), '*', D('556.00'), 'USD', D('11120.00'), D('11780.30'), D('589.015'), None), ('Assets:Account2', D('20'), 'HOOL', D('519.24'), 'USD', D('10384.80'), D('11622.00'), D('581.10'), datetime.date(2014, 2, 15)), ('Assets:Cash', D('101.11'), 'USD', None, None, None, None, None, None), ])) self.assertEqual(expected_holdings, holdings.aggregate_holdings_by(test_holdings, lambda holding: holding.account))
def report_holdings(currency, relative, entries, options_map, aggregation_key=None, sort_key=None): """Generate a detailed list of all holdings. Args: currency: A string, a currency to convert to. If left to None, no conversion is carried out. relative: A boolean, true if we should reduce this to a relative value. entries: A list of directives. options_map: A dict of parsed options. aggregation_key: A callable use to generate aggregations. sort_key: A function to use to sort the holdings, if specified. Returns: A Table instance. """ holdings_list, _ = holdings.get_assets_holdings(entries, options_map, currency) if aggregation_key: holdings_list = holdings.aggregate_holdings_by(holdings_list, aggregation_key) if relative: holdings_list = holdings.reduce_relative(holdings_list) field_spec = RELATIVE_FIELD_SPEC else: field_spec = FIELD_SPEC if sort_key: holdings_list.sort(key=sort_key, reverse=True) return table.create_table(holdings_list, field_spec)
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_aggregate_holdings__same_price_same_date(self): test_holdings = list(itertools.starmap(holdings.Holding, [ ('Assets:Account1', D('10'), 'HOOL', D('500'), 'USD', D('5000'), D('6000'), D('600'), datetime.date(2014, 2, 1)), ('Assets:Account1', D('20'), 'HOOL', D('530'), 'USD', D('10600'), D('12000'), D('600'), datetime.date(2014, 2, 1)), ])) expected_holdings = sorted(itertools.starmap(holdings.Holding, [ ('Assets:Account1', D('30'), 'HOOL', D('520'), 'USD', D('15600'), D('18000'), D('600'), datetime.date(2014, 2, 1)), ])) self.assertEqual(expected_holdings, holdings.aggregate_holdings_by(test_holdings, lambda holding: holding.account))
def get_networth(entries, options_map, args): net_worths = [] index = 0 current_entries = [] dtend = datetime.date.today() period = rrule.rrule(rrule.MONTHLY, bymonthday=1, dtstart=args.min_date, until=dtend) for dtime in period: date = dtime.date() # Append new entries until the given date. while True: entry = entries[index] if entry.date >= date: break current_entries.append(entry) index += 1 # Get the list of holdings. raw_holdings_list, price_map = holdings.get_assets_holdings(current_entries, options_map) # Remove any accounts we don't in our final total filtered_holdings_list = [n for n in raw_holdings_list if n.account not in args.ignore_account] # Convert the currencies. holdings_list = holdings.convert_to_currency(price_map, args.currency, filtered_holdings_list) holdings_list = holdings.aggregate_holdings_by( holdings_list, lambda holding: holding.cost_currency) holdings_list = [holding for holding in holdings_list if holding.currency and holding.cost_currency] # If after conversion there are no valid holdings, skip the currency # altogether. if not holdings_list: continue # TODO: How can something have a book_value but not a market_value? value = holdings_list[0].market_value or holdings_list[0].book_value net_worths.append((date, float(value))) logging.debug("{}: {:,.2f}".format(date, value)) return pandas.Series(dict(net_worths))
def test_aggregate_holdings__diff_price_diff_date(self): test_holdings = list(itertools.starmap(holdings.Holding, [ ('Assets:Account1', D('10'), 'HOOL', D('500'), 'USD', D('5000'), D('6000'), D('610'), datetime.date(2014, 2, 1)), ('Assets:Account1', D('20'), 'HOOL', D('530'), 'USD', D('10600'), D('12000'), D('600'), datetime.date(2014, 2, 2)), ])) # Price is recalculated from the market value, date is maintained. expected_holdings = sorted(itertools.starmap(holdings.Holding, [ ('Assets:Account1', D('30'), 'HOOL', D('520'), 'USD', D('15600'), D('18000'), D('600'), None), ])) self.assertEqual(expected_holdings, holdings.aggregate_holdings_by(test_holdings, lambda holding: holding.account))
def _holdings_to_net_worth(self, holdings_list): totals = {} for currency in self.options['operating_currency']: currency_holdings_list = \ holdings.convert_to_currency(self.price_map, currency, holdings_list) holdings_ = holdings.aggregate_holdings_by( currency_holdings_list, operator.attrgetter('cost_currency')) holdings_ = [holding for holding in holdings_ if holding.currency and holding.cost_currency] if holdings_: totals[currency] = holdings_[0].market_value else: totals[currency] = 0 return totals
def _holdings_to_net_worth(self, holdings_list): totals = {} for currency in self.options['operating_currency']: currency_holdings_list = \ holdings.convert_to_currency(self.price_map, currency, holdings_list) if not currency_holdings_list: continue holdings_ = holdings.aggregate_holdings_by( currency_holdings_list, operator.attrgetter('cost_currency')) holdings_ = [holding for holding in holdings_ if holding.currency and holding.cost_currency] # If after conversion there are no valid holdings, skip the # currency altogether. if holdings_: totals[currency] = holdings_[0].market_value return totals
def generate_table(self, entries, errors, options_map): holdings_list, price_map = get_assets_holdings(entries, options_map) net_worths = [] for currency in options_map['operating_currency']: # Convert holdings to a unified currency. # # Note: It's entirely possible that the price map does not have all # the necessary rate conversions here. The resulting holdings will # simply have no cost when that is the case. We must handle this # gracefully below. currency_holdings_list = holdings.convert_to_currency( price_map, currency, holdings_list) if not currency_holdings_list: continue holdings_list = holdings.aggregate_holdings_by( currency_holdings_list, lambda holding: holding.cost_currency) holdings_list = [ holding for holding in holdings_list if holding.currency and holding.cost_currency ] # If after conversion there are no valid holdings, skip the currency # altogether. if not holdings_list: continue net_worths.append((currency, holdings_list[0].market_value)) field_spec = [ (0, 'Currency'), (1, 'Net Worth', '{:,.2f}'.format), ] return table.create_table(net_worths, field_spec)
def _net_worth_in_periods(self): month_tuples = self._interval_tuples('month', self.entries) monthly_totals = [] end_dates = [p[1] for p in month_tuples] for (begin_date, end_date), holdings_list in \ zip(month_tuples, holdings_at_dates(self.entries, end_dates, self.price_map, self.options)): totals = {} for currency in self.options['operating_currency']: currency_holdings_list = \ holdings.convert_to_currency(self.price_map, currency, holdings_list) if not currency_holdings_list: continue holdings_list = holdings.aggregate_holdings_by( currency_holdings_list, operator.attrgetter('cost_currency')) holdings_list = [holding for holding in holdings_list if holding.currency and holding.cost_currency] # If after conversion there are no valid holdings, skip the # currency altogether. if holdings_list: totals[currency] = holdings_list[0].market_value monthly_totals.append({ 'begin_date': begin_date, 'end_date': end_date, 'totals': totals }) return monthly_totals
def export_holdings(entries, options_map, promiscuous, aggregate_by_commodity=False): """Compute a list of holdings to export. Holdings that are converted to cash equivalents will receive a currency of "CASH:<currency>" where <currency> is the converted cash currency. Args: entries: A list of directives. options_map: A dict of options as provided by the parser. promiscuous: A boolean, true if we should output a promiscuious memo. aggregate_by_commodity: A boolean, true if we should group the holdings by account. Returns: A pair of exported: A list of ExportEntry tuples, one for each exported position. converted: A list of ExportEntry tuples, one for each converted position. These will contain multiple holdings. holdings_ignored: A list of Holding instances that were ignored, either because they were explicitly marked to be ignored, or because we could not convert them to a money vehicle matching the holding's cost-currency. """ # Get the desired list of holdings. holdings_list, price_map = holdings_reports.get_assets_holdings( entries, options_map) commodities_map = getters.get_commodity_map(entries) dcontext = options_map['dcontext'] # Aggregate the holdings, if requested. Google Finance is notoriously # finnicky and if you have many holdings this might help. if aggregate_by_commodity: holdings_list = holdings.aggregate_holdings_by( holdings_list, lambda holding: holding.currency) # Classify all the holdings for export. action_holdings = classify_holdings_for_export(holdings_list, commodities_map) # The lists of exported and converted export entries, and the list of # ignored holdings. exported = [] converted = [] holdings_ignored = [] # Export the holdings with tickers individually. for symbol, holding in action_holdings: if symbol in ("CASH", "IGNORE"): continue if holding.cost_number is None: assert holding.cost_currency in (None, holding.currency) cost_number = holding.number cost_currency = holding.currency else: cost_number = holding.cost_number cost_currency = holding.cost_currency exported.append( ExportEntry(symbol, cost_currency, holding.number, cost_number, is_mutual_fund(symbol), holding.account if promiscuous else '', [holding])) # Convert all the cash entries to their book and market value by currency. cash_holdings_map = collections.defaultdict(list) for symbol, holding in action_holdings: if symbol != "CASH": continue if holding.cost_currency: # Accumulate market and book values. cash_holdings_map[holding.cost_currency].append(holding) else: # We cannot price this... no cost currency. holdings_ignored.append(holding) # Get the money instruments. money_instruments = get_money_instruments(commodities_map) # Convert all the cash values to money instruments, if possible. If not # possible, we'll just have to ignore those values. # Go through all the holdings to convert, and for each of those which aren't # in terms of one of the money instruments, which we can directly add to the # exported portfolio, attempt to convert them into currencies to one of # those in the money instruments. money_values_book = collections.defaultdict(D) money_values_market = collections.defaultdict(D) money_values_holdings = collections.defaultdict(list) for cost_currency, holdings_list in cash_holdings_map.items(): book_value = sum(holding.book_value for holding in holdings_list) market_value = sum(holding.market_value for holding in holdings_list) if cost_currency in money_instruments: # The holding is already in terms of one of the money instruments. money_values_book[cost_currency] += book_value money_values_market[cost_currency] += market_value money_values_holdings[cost_currency].extend(holdings_list) else: # The holding is not in terms of one of the money instruments. # Find the first available price to convert it into one for money_currency in money_instruments: base_quote = (cost_currency, money_currency) _, rate = prices.get_latest_price(price_map, base_quote) if rate is not None: money_values_book[money_currency] += book_value * rate money_values_market[money_currency] += market_value * rate money_values_holdings[money_currency].extend(holdings_list) break else: # We could not convert into any of the money commodities. Ignore # those holdings. holdings_ignored.extend(holdings_list) for money_currency in money_values_book.keys(): book_value = money_values_book[money_currency] market_value = money_values_market[money_currency] holdings_list = money_values_holdings[money_currency] symbol = money_instruments[money_currency] assert isinstance(book_value, Decimal) assert isinstance(market_value, Decimal) converted.append( ExportEntry( symbol, money_currency, dcontext.quantize(market_value, money_currency), dcontext.quantize(book_value / market_value, money_currency), is_mutual_fund(symbol), '', holdings_list)) # Add all ignored holdings to a final list. for symbol, holding in action_holdings: if symbol == "IGNORE": holdings_ignored.append(holding) return exported, converted, holdings_ignored
def main(): import argparse, logging logging.basicConfig(level=logging.INFO, format='%(levelname)-8s: %(message)s') parser = argparse.ArgumentParser(description=__doc__.strip()) parser.add_argument('--min-date', action='store', type=lambda string: parse(string).date(), help="Minimum date") parser.add_argument('-o', '--output', action='store', help="Save the figure to the given file") parser.add_argument('--hide', action='store_true', help="Mask out the vertical axis") parser.add_argument('--period', choices=['weekly', 'monthly', 'daily'], default='weekly', help="Period of aggregation") parser.add_argument('filename', help='Beancount input filename') args = parser.parse_args() entries, errors, options_map = loader.load_file(args.filename) if args.min_date: dtstart = args.min_date else: for entry in entries: if isinstance(entry, data.Transaction): dtstart = entry.date break net_worths_dict = collections.defaultdict(list) index = 0 current_entries = [] dtend = datetime.date.today() if args.period == 'weekly': period = rrule.rrule(rrule.WEEKLY, byweekday=rrule.FR, dtstart=dtstart, until=dtend) elif args.period == 'monthly': period = rrule.rrule(rrule.MONTHLY, bymonthday=1, dtstart=dtstart, until=dtend) elif args.period == 'daily': period = rrule.rrule(rrule.DAILY, dtstart=dtstart, until=dtend) for dtime in period: date = dtime.date() logging.info(date) # Append new entries until the given date. while True: entry = entries[index] if entry.date >= date: break current_entries.append(entry) index += 1 # Get the list of holdings. raw_holdings_list, price_map = holdings_reports.get_assets_holdings( current_entries, options_map) # Convert the currencies. for currency in options_map['operating_currency']: holdings_list = holdings.convert_to_currency( price_map, currency, raw_holdings_list) holdings_list = holdings.aggregate_holdings_by( holdings_list, lambda holding: holding.cost_currency) holdings_list = [ holding for holding in holdings_list if holding.currency and holding.cost_currency ] # If after conversion there are no valid holdings, skip the currency # altogether. if not holdings_list: continue net_worths_dict[currency].append( (date, holdings_list[0].market_value)) # Extrapolate milestones in various currencies. days_interp = 365 if args.period == 'weekly': num_points = int(days_interp / 7) elif args.period == 'monthly': num_points = int(days_interp / 30) elif args.period == 'daily': num_points = 365 lines = [] today = datetime.date.today() for currency, currency_data in net_worths_dict.items(): recent_data = currency_data[-num_points:] dates = [time.mktime(date.timetuple()) for date, _ in recent_data] values = [float(value) for _, value in recent_data] poly = numpy.poly1d(numpy.polyfit(dates, values, 1)) logging.info("Extrapolations based on the last %s data points for %s:", num_points, currency) for amount in EXTRAPOLATE_WORTHS: try: date_reach = date.fromtimestamp( (amount - poly.c[1]) / poly.c[0]) if date_reach < today: continue time_until = (date_reach - today).days / 365. logging.info("%10d %s: %s (%.1f years)", amount, currency, date_reach, time_until) except OverflowError: pass logging.info("Time to save 1M %s: %.1f years", currency, (1000000 / poly.c[0]) / (365 * 24 * 60 * 60)) dates = [today - datetime.timedelta(days=days_interp), today] amounts = [ time.mktime(date.timetuple()) * poly.c[0] + poly.c[1] for date in dates ] lines.append((dates, amounts)) # Plot each operating currency as a separate curve. for currency, currency_data in net_worths_dict.items(): dates = [date for date, _ in currency_data] values = [float(value) for _, value in currency_data] pyplot.plot(dates, values, '-', label=currency) pyplot.tight_layout() pyplot.title("Net Worth") pyplot.legend(loc=2) if args.hide: pyplot.yticks([]) for dates, amounts in lines: pyplot.plot(dates, amounts, 'k--') # Output the plot. if args.output: pyplot.savefig(args.output, figsize=(11, 8), dpi=600) pyplot.show()
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(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