def balance_check(entries, options_map): errors = [] tracking_accounts = set() for entry in entries: if isinstance(entry, Open): if entry.meta.get('tracking', False): tracking_accounts.add(entry.account) asum = Inventory() bsum = Inventory() for entry in filter_txns(entries): for posting in entry.postings: if posting.account in tracking_accounts: continue components = posting.account.split(':') if components[0] in ('Assets', 'Liabilities'): asum.add_position(posting) elif components[0] in ('Income', 'Expenses'): bsum.add_position(posting) csum = asum.reduce(convert.get_weight) + bsum.reduce(convert.get_weight) if not csum.is_small(interpolate.infer_tolerances({}, options_map)): errors.append( BudgetBalanceError( { 'filename': '<budget_balance_check>', 'lineno': 0 }, f"On-budget accounts and budget total do not match: {asum} vs {-bsum}", None)) return entries, errors
def inventory_at_dates(transactions, dates, posting_predicate): """Generator that yields the aggregate inventory at the specified dates. The inventory for a specified date includes all matching postings PRIOR to it. :param transactions: list of transactions, sorted by date. :param dates: iterator of dates :param posting_predicate: predicate with the Transaction and Posting to decide whether to include the posting in the inventory. """ index = 0 length = len(transactions) # inventory maps lot to amount inventory = Inventory() prev_date = None for date in dates: assert prev_date is None or date > prev_date prev_date = date while index < length and transactions[index].date < date: entry = transactions[index] index += 1 for posting in entry.postings: if posting_predicate(posting): inventory.add_position(posting) yield inventory
def interval_totals( self, interval: Interval, accounts: Union[str, Tuple[str]], conversion: str, ): """Renders totals for account (or accounts) in the intervals. Args: interval: An interval. accounts: A single account (str) or a tuple of accounts. conversion: The conversion to use. """ price_map = self.ledger.price_map for begin, end in pairwise(self.ledger.interval_ends(interval)): inventory = Inventory() entries = iter_entry_dates(self.ledger.entries, begin, end) for entry in (e for e in entries if isinstance(e, Transaction)): for posting in entry.postings: if posting.account.startswith(accounts): inventory.add_position(posting) yield { "date": begin, "balance": cost_or_value(inventory, conversion, price_map, end - ONE_DAY), "budgets": self.ledger.budgets.calculate_children(accounts, begin, end), }
def measure_balance_total(entries): # For timing only. with utils.log_time('balance_all', logging.info): # 89ms... okay. balance = Inventory() for entry in utils.filter_type(entries, data.Transaction): for posting in entry.postings: balance.add_position(posting.position)
def test_op_add(self): inv1 = Inventory.from_string('17.00 USD') orig_inv1 = Inventory.from_string('17.00 USD') inv2 = Inventory.from_string('21.00 CAD') inv3 = inv1 + inv2 self.assertEqual(Inventory.from_string('17.00 USD, 21.00 CAD'), inv3) self.assertEqual(orig_inv1, inv1)
def test_update(self): inv1 = Inventory.from_string('11 USD') inv2 = Inventory.from_string('12 CAD') inv_updated = inv1.add_inventory(inv2) expect_updated = Inventory.from_string('11 USD, 12 CAD') self.assertEqual(expect_updated, inv_updated) self.assertEqual(expect_updated, inv1)
def inventory_at_dates(transactions, dates, posting_predicate): """Generator that yields the aggregate inventory at the specified dates. The inventory for a date includes all matching postings PRIOR to it. Args: transactions: list of transactions, sorted by date. dates: iterator of dates posting_predicate: predicate with the Transaction and Posting to decide whether to include the posting in the inventory. """ if not transactions: return iterator = iter(transactions) txn = next(iterator) inventory = Inventory() for date in dates: while txn.date < date: for posting in txn.postings: if posting_predicate(posting): inventory.add_position(posting) try: txn = next(iterator) except StopIteration: break yield inventory.get_positions()
def net_worth(self, interval): """Compute net worth. Args: interval: A string for the interval. Returns: A list of dicts for all ends of the given interval containing the net worth (Assets + Liabilities) separately converted to all operating currencies. """ transactions = (entry for entry in self.ledger.entries if (isinstance(entry, Transaction) and entry.flag != flags.FLAG_UNREALIZED)) types = ( self.ledger.options["name_assets"], self.ledger.options["name_liabilities"], ) txn = next(transactions, None) inventory = Inventory() for end_date_exclusive in self.ledger.interval_ends(interval): end_date_inclusive = end_date_exclusive - datetime.timedelta( days=1) while txn and txn.date < end_date_exclusive: for posting in txn.postings: if posting.account.startswith(types): inventory.add_position(posting) txn = next(transactions, None) yield { "date": end_date_exclusive, "balance": cost_or_value(inventory, end_date_inclusive), }
def process_account_entries(entries: data.Entries, options_map: data.Options, account: Account) -> AccountData: """Process a single account.""" logging.info("Processing account: %s", account) # Extract the relevant transactions. transactions = transactions_for_account(entries, account) if not transactions: logging.warning("No transactions for %s; skipping.", account) return transactions, None, None # Categorize the set of accounts encountered in the filtered transactions. seen_accounts = { posting.account for entry in transactions for posting in entry.postings } atypes = options.get_account_types(options_map) catmap = categorize_accounts(account, seen_accounts, atypes) # Process each of the transactions, adding derived values as metadata. cash_flows = [] balance = Inventory() decorated_transactions = [] for entry in transactions: # Update the total position in the asset we're interested in. positions = [] for posting in entry.postings: category = catmap[posting.account] if category is Cat.ASSET: balance.add_position(posting) positions.append(posting) # Compute the signature of the transaction. entry = copy_and_normalize(entry) signature = compute_transaction_signature(catmap, entry) entry.meta["signature"] = signature entry.meta["description"] = KNOWN_SIGNATURES[signature] # Compute the cash flows associated with the transaction. flows = produce_cash_flows(entry) entry.meta['cash_flows'] = flows cash_flows.extend( flow._replace(balance=copy.deepcopy(balance)) for flow in flows) decorated_transactions.append(entry) currency = accountlib.leaf(account) cost_currencies = set(cf.amount.currency for cf in cash_flows) assert len(cost_currencies) == 1, str(cost_currencies) cost_currency = cost_currencies.pop() commodity_map = getters.get_commodity_directives(entries) comm = commodity_map[currency] return AccountData(account, currency, cost_currency, comm, cash_flows, decorated_transactions, catmap)
def interval_totals( self, filtered: FilteredLedger, interval: Interval, accounts: str | tuple[str], conversion: str, invert: bool = False, ) -> Generator[DateAndBalanceWithBudget, None, None]: """Renders totals for account (or accounts) in the intervals. Args: interval: An interval. accounts: A single account (str) or a tuple of accounts. conversion: The conversion to use. invert: invert all numbers. """ # pylint: disable=too-many-locals price_map = self.ledger.price_map for begin, end in pairwise(filtered.interval_ends(interval)): inventory = Inventory() entries = iter_entry_dates(filtered.entries, begin, end) account_inventories = {} for entry in (e for e in entries if isinstance(e, Transaction)): for posting in entry.postings: if posting.account.startswith(accounts): if posting.account not in account_inventories: account_inventories[posting.account] = Inventory() account_inventories[posting.account].add_position( posting) inventory.add_position(posting) balance = cost_or_value(inventory, conversion, price_map, end - ONE_DAY) account_balances = {} for account, acct_value in account_inventories.items(): account_balances[account] = cost_or_value( acct_value, conversion, price_map, end - ONE_DAY, ) budgets = {} if isinstance(accounts, str): budgets = self.ledger.budgets.calculate_children( accounts, begin, end) if invert: # pylint: disable=invalid-unary-operand-type balance = -balance budgets = {k: -v for k, v in budgets.items()} account_balances = {k: -v for k, v in account_balances.items()} yield DateAndBalanceWithBudget( begin, balance, account_balances, budgets, )
def test_currency_pairs(self): inv = Inventory() self.assertEqual(set(), inv.currency_pairs()) inv = I('40 USD {1.01 CAD}, 40 USD') self.assertEqual(set([('USD', 'CAD'), ('USD', None)]), inv.currency_pairs()) inv = I('40 AAPL {1.01 USD}, 10 HOOL {2.02 USD}') self.assertEqual(set([('AAPL', 'USD'), ('HOOL', 'USD')]), inv.currency_pairs())
def test_sum_inventories(self): inv1 = Inventory() inv1.add_amount(A('10 USD')) inv2 = Inventory() inv2.add_amount(A('20 CAD')) inv2.add_amount(A('55 HOOL')) _ = inv1 + inv2
def test_currencies(self): inv = Inventory() self.assertEqual(set(), inv.currencies()) inv = I('40 USD {1.01 CAD}, 40 USD') self.assertEqual({'USD'}, inv.currencies()) inv = I('40 AAPL {1.01 USD}, 10 HOOL {2.02 USD}') self.assertEqual({'AAPL', 'HOOL'}, inv.currencies())
def test_op_neg(self): inv = Inventory() inv.add_amount(A('10 USD')) ninv = -inv self.checkAmount(ninv, '-10', 'USD') pinv = I('1.50 JPY, 1.51 USD, 1.52 CAD') ninv = I('-1.50 JPY, -1.51 USD, -1.52 CAD') self.assertEqual(pinv, -ninv)
def test_is_mixed(self): inv = Inventory.from_string('100 HOOL {250 USD}, 101 HOOL {251 USD}') self.assertFalse(inv.is_mixed()) inv = Inventory.from_string('100 HOOL {250 USD}, -1 HOOL {251 USD}') self.assertTrue(inv.is_mixed()) inv = Inventory.from_string('-2 HOOL {250 USD}, -1 HOOL {251 USD}') self.assertFalse(inv.is_mixed())
def test_get_position(self): inv = Inventory(self.POSITIONS_ALL_KINDS) self.assertEqual( position.from_string('40.50 USD'), inv.get_position(Lot('USD', None, None))) self.assertEqual( position.from_string('40.50 USD {1.10 CAD}'), inv.get_position(Lot('USD', A('1.10 CAD'), None))) self.assertEqual( position.from_string('40.50 USD {1.10 CAD, 2012-01-01}'), inv.get_position(Lot('USD', A('1.10 CAD'), date(2012, 1, 1))))
def compute_balance_at(transactions: data.Entries, date: Optional[Date] = None) -> Inventory: """Compute the balance at a specific date.""" balance = Inventory() for entry in transactions: if date is not None and entry.date >= date: break for posting in entry.postings: if posting.meta["category"] is Cat.ASSET: balance.add_position(posting) return balance
def test_copy(self): inv = Inventory() inv.add_amount(A('100.00 USD')) self.checkAmount(inv, '100', 'USD') # Test copying. inv2 = copy.copy(inv) inv2.add_amount(A('50.00 USD')) self.checkAmount(inv2, '150', 'USD') # Check that the original object is not modified. self.checkAmount(inv, '100', 'USD')
def get_inventory(self, account, date): inventory = Inventory() for entry in self.entries: if date is not None and entry.date > date: break if not isinstance(entry, Transaction): continue for posting in entry.postings: if posting.account != account: continue inventory.add_position(get_position(posting)) return inventory
def _total_balance(self, names, begin_date, end_date): totals = [realization.compute_balance( _real_account(account_name, self.ledger.entries, begin_date, end_date)) for account_name in names] return _serialize_inventory(sum(totals, Inventory()), at_cost=True)
def test_get_units(self): inv = Inventory.from_string('40.50 JPY, 40.51 USD {1.01 CAD}, 40.52 CAD') self.assertEqual(inv.get_units('JPY'), A('40.50 JPY')) self.assertEqual(inv.get_units('USD'), A('40.51 USD')) self.assertEqual(inv.get_units('CAD'), A('40.52 CAD')) self.assertEqual(inv.get_units('AUD'), A('0 AUD')) self.assertEqual(inv.get_units('NZD'), A('0 NZD'))
def test_is_small__dict(self): test_inv = Inventory.from_string('0.03 JPY, 0.003 USD') for inv in test_inv, -test_inv: # Test all four types of inequalities. self.assertTrue(inv.is_small({'JPY': D('0.05'), 'USD': D('0.005')})) self.assertFalse(inv.is_small({'JPY': D('0.005'), 'USD': D('0.0005')})) self.assertTrue(inv.is_small({'JPY': D('0.05'), 'USD': D('0.5')})) self.assertFalse(inv.is_small({'JPY': D('0.005'), 'USD': D('0.005')})) # Test border case and an epsilon under. self.assertTrue(inv.is_small({'JPY': D('0.03'), 'USD': D('0.003')})) self.assertFalse(inv.is_small({'JPY': D('0.02999999999999'), 'USD': D('0.003')})) self.assertFalse(inv.is_small({'JPY': D('0.03'), 'USD': D('0.00299999')})) # Test missing precisions. self.assertFalse(inv.is_small({'JPY': D('0.05')})) self.assertFalse(inv.is_small({'USD': D('0.005')})) # Test extra precisions. self.assertTrue(inv.is_small({'JPY': D('0.05'), 'USD': D('0.005'), 'CAD': D('0.0005')})) # Test no precisions. self.assertFalse(inv.is_small({}))
def test_is_small__value(self): test_inv = Inventory.from_string('1.50 JPY, 1.51 USD, 1.52 CAD') for inv in test_inv, -test_inv: self.assertFalse(inv.is_small(D('1.49'))) self.assertFalse(inv.is_small(D('1.50'))) self.assertTrue(inv.is_small(D('1.53'))) self.assertTrue(inv.is_small(D('1.52')))
def test_op_eq(self): inv1 = Inventory.from_string('100 USD, 100 CAD') inv2 = Inventory.from_string('100 CAD, 100 USD') self.assertEqual(inv1, inv2) self.assertEqual(inv2, inv1) inv3 = Inventory.from_string('200 USD, 100 CAD') self.assertNotEqual(inv1, inv3) self.assertNotEqual(inv3, inv1) inv4 = Inventory.from_string('100 USD, 100 JPY') self.assertNotEqual(inv1, inv4) self.assertNotEqual(inv4, inv1) inv5 = Inventory.from_string('100 JPY, 100 USD') self.assertEqual(inv4, inv5)
def find_loose_cash(accapi, options): """Find uninvested cash in specified accounts""" currencies_pattern = find_cash_commodities(accapi, options) sql = """ SELECT account AS account, sum(position) AS position WHERE account ~ '{accounts_pattern}' AND not account ~ '{accounts_exclude_pattern}' AND currency ~ '{currencies_pattern}' GROUP BY account ORDER BY sum(position) DESC """.format( accounts_pattern=options.get('accounts_pattern', '^Assets'), accounts_exclude_pattern=options.get('accounts_exclude_pattern', '^ $'), # TODO currencies_pattern=currencies_pattern, ) rtypes, rrows = accapi.query_func(sql) if not rtypes: return [], {}, [[]] rrows = [r for r in rrows if r.position != Inventory()] footer = libinvestor.build_table_footer(rtypes, rrows, accapi) return rtypes, rrows, None, footer
def compute_portfolio_values( price_map: prices.PriceMap, transactions: data.Entries) -> Tuple[List[Date], List[float]]: """Compute a serie of portfolio values over time.""" # Infer the list of required prices. currency_pairs = set() for entry in transactions: for posting in entry.postings: if posting.meta["category"] is Cat.ASSET: if posting.cost: currency_pairs.add( (posting.units.currency, posting.cost.currency)) first = lambda x: x[0] price_dates = sorted(itertools.chain( ((date, None) for pair in currency_pairs for date, _ in prices.get_all_prices(price_map, pair)), ((entry.date, entry) for entry in transactions)), key=first) # Iterate computing the balance. value_dates = [] value_values = [] balance = Inventory() for date, group in itertools.groupby(price_dates, key=first): # Update balances. for _, entry in group: if entry is None: continue for posting in entry.postings: if posting.meta["category"] is Cat.ASSET: balance.add_position(posting) # Convert to market value. value_balance = balance.reduce(convert.get_value, price_map, date) cost_balance = value_balance.reduce(convert.convert_position, "USD", price_map) pos = cost_balance.get_only_position() value = pos.units.number if pos else ZERO # Add one data point. value_dates.append(date) value_values.append(value) return value_dates, value_values
def net_worth( self, interval: Interval, conversion: str ) -> Generator[DateAndBalance, None, None]: """Compute net worth. Args: interval: A string for the interval. conversion: The conversion to use. Returns: A list of dicts for all ends of the given interval containing the net worth (Assets + Liabilities) separately converted to all operating currencies. """ transactions = ( entry for entry in self.ledger.entries if ( isinstance(entry, Transaction) and entry.flag != FLAG_UNREALIZED ) ) types = ( self.ledger.options["name_assets"], self.ledger.options["name_liabilities"], ) txn = next(transactions, None) inventory = Inventory() price_map = self.ledger.price_map for end_date in self.ledger.interval_ends(interval): while txn and txn.date < end_date: for posting in txn.postings: if posting.account.startswith(types): inventory.add_position(posting) txn = next(transactions, None) yield { "date": end_date, "balance": cost_or_value( inventory, conversion, price_map, end_date - ONE_DAY ), }
def tracking(entries, options_map): account_types = get_account_types(options_map) income_tracking = set() expense_tracking = set() errors = [] new_entries = [] for entry in entries: new_entry = None if isinstance(entry, Open) and entry.meta.get('tracking', False): if is_account_type(account_types.expenses, entry.account): expense_tracking.add(entry.account) elif is_account_type(account_types.income, entry.account): income_tracking.add(entry.account) elif isinstance(entry, Transaction): new_postings = [] tracking_balance = Inventory() for posting in entry.postings: if 'tracking' in posting.meta: new_acct = posting.meta['tracking'] new_posting = posting._replace(account=new_acct, meta=None) new_postings.append(new_posting) tracking_balance.add_position(posting) if new_postings: for position in -tracking_balance: if position.units.number < 0 and len(income_tracking) == 1: posting_acct, = income_tracking elif position.units.number > 0 and len( expense_tracking) == 1: posting_acct, = expense_tracking else: continue new_posting = Posting(posting_acct, position.units, position.cost, None, None, None) new_postings.append(new_posting) link_id = 'tracking-' + compare.hash_entry(entry) new_links = entry.links | set([link_id]) entry = entry._replace(links=new_links) new_entry = entry._replace(postings=new_postings) new_entries.append(entry) if new_entry: new_entries.append(new_entry) return new_entries, errors
def interval_totals( self, interval: Interval, accounts: Union[str, Tuple[str]], conversion: str, invert: bool = False, ) -> Generator[DateAndBalanceWithBudget, None, None]: """Renders totals for account (or accounts) in the intervals. Args: interval: An interval. accounts: A single account (str) or a tuple of accounts. conversion: The conversion to use. """ price_map = self.ledger.price_map for begin, end in pairwise(self.ledger.interval_ends(interval)): inventory = Inventory() entries = iter_entry_dates(self.ledger.entries, begin, end) for entry in (e for e in entries if isinstance(e, Transaction)): for posting in entry.postings: if posting.account.startswith(accounts): inventory.add_position(posting) balance = cost_or_value( inventory, conversion, price_map, end - ONE_DAY ) budgets = {} if isinstance(accounts, str): budgets = self.ledger.budgets.calculate_children( accounts, begin, end ) if invert: # pylint: disable=invalid-unary-operand-type balance = -balance budgets = {k: -v for k, v in budgets.items()} yield { "date": begin, "balance": balance, "budgets": budgets, }
def find_balance_before(cash_flows: List[CashFlow], date: Date) -> Tuple[Inventory, int]: """Return the balance just before the given date in the sorted list of cash flows.""" balance = Inventory() for index, flow in enumerate(cash_flows): if flow.date >= date: break balance = flow.balance else: index = len(cash_flows) return balance, index
def test_add_allow_negative(self): def check_allow_negative(inv): position_, _ = inv.add_amount(A('-11 USD')) self.assertFalse(position_.is_negative_at_cost()) position_, _ = inv.add_amount(A('-11 USD'), A('1.10 CAD')) self.assertTrue(position_.is_negative_at_cost()) position_, _ = inv.add_amount(A('-11 USD'), None, date(2012, 1, 1)) self.assertTrue(position_.is_negative_at_cost()) inv.add_amount(A('-11 USD'), A('1.10 CAD')) inv.add_amount(A('-11 USD'), None, date(2012, 1, 1)) # Test adding to a position that does not exist. inv = Inventory() check_allow_negative(inv) # Test adding to a position that does exist. inv = Inventory.from_string( '10 USD, 10 USD {1.10 CAD}, 10 USD {1.10 CAD, 2012-01-01}') check_allow_negative(inv)