Exemple #1
0
    def test_get_latest_price(self, entries, _, __):
        """
        2013-06-01 price  USD  1.01 CAD
        2013-06-09 price  USD  1.09 CAD
        2013-06-11 price  USD  1.11 CAD
        """
        price_map = prices.build_price_map(entries)
        price_list = prices.get_latest_price(price_map, ('USD', 'CAD'))
        expected = (datetime.date(2013, 6, 11), D('1.11'))
        self.assertEqual(expected, price_list)

        # Test not found.
        result = prices.get_latest_price(price_map, ('EWJ', 'JPY'))
        self.assertEqual((None, None), result)
Exemple #2
0
def convert_to_currency(price_map, target_currency, holdings_list):
    """Convert the given list of holdings's fields to a common currency.

    If the rate is not available to convert, leave the fields empty.

    Args:
      price_map: A price-map, as built by prices.build_price_map().
      target_currency: The target common currency to convert amounts to.
      holdings_list: A list of holdings.Holding instances.
    Returns:
      A modified list of holdings, with the 'extra' field set to the value in
      'currency', or None, if it was not possible to convert.
    """
    # A list of the fields we should convert.
    convert_fields = ('cost_number', 'book_value', 'market_value', 'price_number')

    new_holdings = []
    for holding in holdings_list:
        if holding.cost_currency == target_currency:
            # The holding is already priced in the target currency; do nothing.
            new_holding = holding
        else:
            if holding.cost_currency is None:
                # There is no cost currency; make the holding priced in its own
                # units. The price-map should yield a rate of 1.0 and everything
                # else works out.
                if holding.currency is None:
                    raise ValueError("Invalid currency '{}'".format(holding.currency))
                holding = holding._replace(cost_currency=holding.currency)

                # Fill in with book and market value as well.
                if holding.book_value is None:
                    holding = holding._replace(book_value=holding.number)
                if holding.market_value is None:
                    holding = holding._replace(market_value=holding.number)

            assert holding.cost_currency, "Missing cost currency: {}".format(holding)
            base_quote = (holding.cost_currency, target_currency)

            # Get the conversion rate and replace the required numerical
            # fields..
            _, rate = prices.get_latest_price(price_map, base_quote)
            if rate is not None:
                new_holding = misc_utils.map_namedtuple_attributes(
                    convert_fields,
                    lambda number, r=rate: number if number is None else number * r,
                    holding)
                # Ensure we set the new cost currency after conversion.
                new_holding = new_holding._replace(cost_currency=target_currency)
            else:
                # Could not get the rate... clear every field and set the cost
                # currency to None. This enough marks the holding conversion as
                # a failure.
                new_holding = misc_utils.map_namedtuple_attributes(
                    convert_fields, lambda number: None, holding)
                new_holding = new_holding._replace(cost_currency=None)

        new_holdings.append(new_holding)

    return new_holdings
 def _account_latest_price(self, node):
     # Get latest price date
     quote_price = list(node.balance.keys())[0]
     if quote_price[1] is None:
         latest_price = None
     else:
         base = quote_price[0]
         currency = quote_price[1][1]
         latest_price = prices.get_latest_price(g.ledger.price_map, (currency, base))
     return latest_price
Exemple #4
0
def get_rates_table(entries: data.Entries, currencies: Set[str],
                    main_currency: str) -> Table:
    """Enumerate all the exchange rates."""
    price_map = prices.build_price_map(entries)
    header = ['cost_currency', 'rate_file']
    rows = []
    for currency in currencies:
        _, rate = prices.get_latest_price(price_map, (currency, main_currency))
        if rate is None:
            continue
        rows.append([currency, rate.quantize(PRICE_Q)])
    return Table(header, rows)
Exemple #5
0
def get_prices_table(entries: data.Entries, main_currency: str) -> Table:
    """Enumerate all the prices seen."""
    price_map = prices.build_price_map(entries)
    header = ['currency', 'cost_currency', 'price_file']
    rows = []
    for base_quote in price_map.keys():
        _, price = prices.get_latest_price(price_map, base_quote)
        if price is None:
            continue
        base, quote = base_quote
        rows.append([base, quote, price.quantize(PRICE_Q)])
    return Table(header, rows)
Exemple #6
0
def get_price_jobs_up_to_date(entries,
                              date_last=None,
                              inactive=False,
                              undeclared_source=None,
                              update_rate='weekday',
                              compress_days=1):
    """Get a list of trailing prices to fetch from a stream of entries.

    The list of dates runs from the latest available price up to the latest date.

    Args:
      entries: list of Beancount entries
      date_last: The date up to where to find prices to as an exclusive range end.
      inactive: Include currencies with no balance at the given date. The default
        is to only include those currencies which have a non-zero balance.
      undeclared_source: A string, the name of the default source module to use to
        pull prices for commodities without a price source metadata on their
        Commodity directive declaration.
    Returns:
      A list of DatedPrice instances.
    """
    price_map = prices.build_price_map(entries)

    # Find the list of declared currencies, and from it build a mapping for
    # tickers for each (base, quote) pair. This is the only place tickers
    # appear.
    declared_triples = find_currencies_declared(entries, date_last)
    currency_map = {(base, quote): psources
                    for base, quote, psources in declared_triples}

    # Compute the initial list of currencies to consider.
    if undeclared_source:
        # Use the full set of possible currencies.
        cur_at_cost = find_prices.find_currencies_at_cost(entries)
        cur_converted = find_prices.find_currencies_converted(
            entries, date_last)
        cur_priced = find_prices.find_currencies_priced(entries, date_last)
        currencies = cur_at_cost | cur_converted | cur_priced
        log_currency_list("Currency held at cost", cur_at_cost)
        log_currency_list("Currency converted", cur_converted)
        log_currency_list("Currency priced", cur_priced)
        default_source = import_source(undeclared_source)
    else:
        # Use the currencies from the Commodity directives.
        currencies = set(currency_map.keys())
        default_source = None

    log_currency_list("Currencies in primary list", currencies)

    # By default, restrict to only the currencies with non-zero balances
    # up to the given date.
    # Also, find the earliest start date to fetch prices from.
    # Look at both latest prices and start dates.
    lifetimes_map = lifetimes.get_commodity_lifetimes(entries)
    commodity_map = getters.get_commodity_directives(entries)
    price_start_dates = {}
    stale_currencies = set()

    if inactive:
        for base_quote in currencies:
            if lifetimes_map[base_quote]:
                # Use first date from lifetime
                lifetimes_map[base_quote] = [(lifetimes_map[base_quote][0][0],
                                              None)]
            else:
                # Insert never active commodities into lifetimes
                # Start from date of currency directive
                base, _ = base_quote
                commodity_entry = commodity_map.get(base, None)
                lifetimes_map[base_quote] = [(commodity_entry.date, None)]
    else:
        #Compress any lifetimes based on compress_days
        lifetimes_map = lifetimes.compress_lifetimes_days(
            lifetimes_map, compress_days)

    #Trim lifetimes based on latest price dates.
    for base_quote in lifetimes_map:
        intervals = lifetimes_map[base_quote]
        result = prices.get_latest_price(price_map, base_quote)
        if (result is None or result[0] is None):
            lifetimes_map[base_quote] = \
                lifetimes.trim_intervals(intervals,
                                         None,
                                         date_last)
        else:
            latest_price_date = result[0]
            date_first = latest_price_date + datetime.timedelta(days=1)
            if date_first < date_last:
                lifetimes_map[base_quote] = \
                    lifetimes.trim_intervals(intervals,
                                            date_first,
                                            date_last)
            else:
                # We don't need to update if we're already up to date.
                lifetimes_map[base_quote] = []

    # Remove currency pairs we can't fetch any prices for.
    if not default_source:
        keys = list(lifetimes_map.keys())
        for key in keys:
            if not currency_map.get(key, None):
                del lifetimes_map[key]

    # Create price jobs based on fetch rate
    if update_rate == 'daily':
        required_prices = lifetimes.required_daily_prices(lifetimes_map,
                                                          date_last,
                                                          weekdays_only=False)
    elif update_rate == 'weekday':
        required_prices = lifetimes.required_daily_prices(lifetimes_map,
                                                          date_last,
                                                          weekdays_only=True)
    elif update_rate == 'weekly':
        required_prices = lifetimes.required_weekly_prices(
            lifetimes_map, date_last)
    else:
        raise ValueError('Invalid Update Rate')

    jobs = []
    # Build up the list of jobs to fetch prices for.
    for key in required_prices:
        date, base, quote = key
        psources = currency_map.get((base, quote), None)
        if not psources:
            psources = [PriceSource(default_source, base, False)]

        jobs.append(DatedPrice(base, quote, date, psources))

    return sorted(jobs)
Exemple #7
0
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 compute_networth_series(since_date, end_date=None):
    if end_date is None:
        end_date = datetime.date.today()
    (entries, errors, options_map) = beancount.loader.load_file(get_ledger_file())
    account_map = get_account_map(entries)

    target_currency = 'CNY'
    curr_date = since_date

    result = []
    prev_networth = None
    prev_disposable_networth = None
    cum_invest_nav = decimal.Decimal("1.0")
    while curr_date < end_date:
        entries_to_date = [entry for entry in entries if entry.date <= curr_date]
        holdings_list, price_map_to_date = holdings_reports.get_assets_holdings(
            entries_to_date, options_map, target_currency
        )
        networth_in_cny = 0
        disposable_networth_in_cny = 0
        for hld in holdings_list:
            acc = account_map[hld.account]
            # 预付但大部分情况下不能兑现的沉没资产
            is_sunk = bool(int(acc.meta.get("sunk", 0)))

            # 不可支配
            nondisposable = bool(int(acc.meta.get("nondisposable", 0)))
            if not nondisposable:
                disposable_networth_in_cny += hld.market_value
            networth_in_cny += hld.market_value

        txs_of_date = [
            entry for entry in entries
            if entry.date == curr_date and
                isinstance(entry, beancount.core.data.Transaction)
        ]

        non_trade_expenses = 0
        non_trade_incomes = 0
        for tx in txs_of_date:
            for posting in tx.postings:
                acc = posting.account
                is_nt_exp = acc.startswith("Expenses:") and not acc.startswith("Expenses:Trade:")
                is_nt_inc = acc.startswith("Income:") and not acc.startswith("Income:Trade:")
                if is_nt_exp or is_nt_inc:
                    if posting.units.currency != target_currency:
                        base_quote = (posting.units.currency, target_currency)
                        _, rate = prices.get_latest_price(price_map_to_date, base_quote)
                    else:
                        rate = 1

                    if is_nt_exp:
                        non_trade_expenses += (posting.units.number * rate)
                    else:
                        non_trade_incomes -= (posting.units.number * rate)

        if prev_networth:
            pnl = networth_in_cny - non_trade_incomes + non_trade_expenses - prev_networth
            pnl_str = ("%.2f" % pnl) if not isinstance(pnl, str) else pnl
            pnl_rate_str = "%.4f%%" % (100 * pnl / prev_disposable_networth)
            cum_invest_nav *= (1 + pnl / prev_disposable_networth)
        else:
            pnl_str = 'n/a'
            pnl_rate_str = 'n/a'

        result.append({
            "日期": curr_date,
            # 净资产=资产 - 负债(信用卡), 包含了沉没资产
            "净资产": "%.2f" % networth_in_cny,
            # 可投资金额=净资产 - 不可支配资产(公积金、预付房租、宽带)
            "可投资金额": "%.2f" % disposable_networth_in_cny,
            # Income:Trade(已了结盈亏、分红) 以外的 Income (包含公积金收入、储蓄利息)
            "非投资收入": "%.2f" % non_trade_incomes,
            # Expenses:Trade 以外的 Expenses (包含社保等支出)
            "非投资支出": "%.2f" % non_trade_expenses,
            "投资盈亏": pnl_str,
            # 投资盈亏% = 当日投资盈亏/昨日可投资金额
            "投资盈亏%": pnl_rate_str,
            "累计净值": "%.4f" % cum_invest_nav,
        })

        curr_date += datetime.timedelta(days=1)
        prev_networth = networth_in_cny
        prev_disposable_networth = disposable_networth_in_cny
    return result
def compute_networth_series(since_date, end_date=None):
    if end_date is None:
        end_date = datetime.date.today()
    (entries, errors,
     options_map) = beancount.loader.load_file(get_ledger_file())
    account_map, commodity_map = get_maps(entries)

    target_currency = 'CNY'
    curr_date = since_date

    result = []
    prev_networth = None
    prev_disposable_networth = None
    cum_invest_nav = decimal.Decimal("1.0")
    cum_invest_nav_ytd = decimal.Decimal("1.0")
    cum_invest_pnl = decimal.Decimal(0)
    cum_invest_pnl_ytd = decimal.Decimal(0)

    while curr_date <= end_date:
        entries_to_date = [
            entry for entry in entries if entry.date <= curr_date
        ]
        holdings_list, price_map_to_date = get_assets_holdings(
            entries_to_date, options_map, target_currency)
        raw_networth_in_cny = decimal.Decimal(0)  # 包含sunk资产的理论净资产
        networth_in_cny = decimal.Decimal(0)  # 不包含sunk资产的净资产
        disposable_networth_in_cny = decimal.Decimal(0)
        dnw_by_asset_class = {}

        for hld in holdings_list:
            if hld.currency == "DAY":
                continue

            acc = account_map[hld.account]
            cmdt = commodity_map[hld.currency]
            if hld.market_value is None:
                raise ValueError(hld)

            raw_networth_in_cny += hld.market_value
            # 预付但大部分情况下不能兑现的沉没资产,比如预付的未来房租
            is_sunk = bool(int(acc.meta.get("sunk", 0)))
            if is_sunk:
                continue

            # 不可支配,比如房租押金
            nondisposable = bool(int(acc.meta.get("nondisposable", 0)))
            if not nondisposable:
                disposable_networth_in_cny += hld.market_value
                asset_class = cmdt.meta["asset-class"]
                if asset_class not in dnw_by_asset_class:
                    dnw_by_asset_class[asset_class] = decimal.Decimal(0)
                dnw_by_asset_class[asset_class] += hld.market_value

            networth_in_cny += hld.market_value

        txs_of_date = [
            entry for entry in entries if entry.date == curr_date
            and isinstance(entry, beancount.core.data.Transaction)
        ]

        non_trade_expenses = decimal.Decimal(0)
        non_trade_incomes = decimal.Decimal(0)
        for tx in txs_of_date:
            is_time_tx = any(
                (posting.units.currency == "DAY" for posting in tx.postings))
            if is_time_tx:
                continue

            for posting in tx.postings:
                acc = posting.account
                is_non_trade_exp = (
                    acc.startswith(EXPENSES_PREFIX)
                    and not acc.startswith(EXPENSES_TRADE_PREFIX)) or (
                        acc.startswith(EXPENSES_PREPAYMENTS_PREFIX))

                is_non_trade_inc = acc.startswith(
                    "Income:") and not acc.startswith("Income:Trade:")
                if is_non_trade_exp or is_non_trade_inc:
                    if posting.units.currency != target_currency:
                        base_quote = (posting.units.currency, target_currency)
                        _, rate = prices.get_latest_price(
                            price_map_to_date, base_quote)
                    else:
                        rate = decimal.Decimal(1)

                    if is_non_trade_exp:
                        non_trade_expenses += (posting.units.number * rate)
                    else:
                        non_trade_incomes -= (posting.units.number * rate)

        if prev_networth:
            pnl = networth_in_cny - non_trade_incomes + non_trade_expenses - prev_networth
            pnl_str = ("%.2f" % pnl) if not isinstance(pnl, str) else pnl
            pnl_rate_str = "%.4f%%" % (100 * pnl / prev_disposable_networth)
            cum_invest_nav *= (1 + pnl / prev_disposable_networth)
            cum_invest_nav_ytd *= (1 + pnl / prev_disposable_networth)
            cum_invest_pnl += pnl
            cum_invest_pnl_ytd += pnl
        else:
            pnl = None
            pnl_str = 'n/a'
            pnl_rate_str = 'n/a'

        daily_status = {
            "日期": curr_date,
            # 理论净资产=总资产 - 负债
            "理论净资产": "%.2f" % raw_networth_in_cny,
            # 净资产=总资产' - 负债(信用卡)- 沉没资产
            "净资产": "%.2f" % networth_in_cny,
            "沉没资产": "%.2f" % (raw_networth_in_cny - networth_in_cny),
            # 可投资金额=净资产 - 不可支配资产(公积金、预付房租、宽带)
            "可投资净资产": "%.2f" % disposable_networth_in_cny,
            # Income:Trade(已了结盈亏、分红) 以外的 Income (包含公积金收入、储蓄利息)
            "非投资收入": "%.2f" % non_trade_incomes,
            # Expenses:Trade 以外的 Expenses (包含社保等支出)
            "非投资支出": "%.2f" % non_trade_expenses,
            "投资盈亏": pnl_str,
            # 投资盈亏% = 当日投资盈亏/昨日可投资金额
            "投资盈亏%": pnl_rate_str,
            "累计净值": "%.4f" % cum_invest_nav,
            "累计盈亏": "%.2f" % cum_invest_pnl,
            "当年净值": "%.4f" % cum_invest_nav_ytd,
            "当年盈亏": "%.2f" % cum_invest_pnl_ytd,
        }

        if curr_date.weekday() >= 5:
            if pnl is not None:
                assert pnl <= 0.01, daily_status  # 预期周末不应该有投资盈亏

        assert abs(
            sum(dnw_by_asset_class.values()) - disposable_networth_in_cny) < 1

        assert set(dnw_by_asset_class.keys()
                   ) <= KNOWN_ASSET_CLASSES, dnw_by_asset_class
        for asset_class in KNOWN_ASSET_CLASSES:
            propotion = 100 * dnw_by_asset_class.get(
                asset_class, 0) / disposable_networth_in_cny
            daily_status[f"{asset_class}%"] = f"{propotion:.2f}%"

        result.append(daily_status)

        next_date = curr_date + datetime.timedelta(days=1)
        if curr_date.year != next_date.year:
            cum_invest_nav_ytd = decimal.Decimal("1.0")
            cum_invest_pnl_ytd = decimal.Decimal(0)
        curr_date = next_date
        prev_networth = networth_in_cny
        prev_disposable_networth = disposable_networth_in_cny
    return result