library(tidyverse)
library(lubridate)
library(scales)
library(ggrepel)
library(tidyquant)
library(jsonlite)
theme_set(theme_minimal())
invisible(Sys.setlocale("LC_TIME", "en_US.UTF-8"))

library(DT)

Intro

We use bonds, debts, fixed income, (and German Obligationen) synonymously. Bond investments are less popular among the young investor communities I follow (Reddit, X, and so on). Young long term investors should maximize their stock exposure and with that their longterm returns. Volatility is less of an issue over the long-term. So are we told.

Yet personally, I currently diversify my portfolio with gold, real estate, and factor equity ETFs. And I should also include bonds, even though I’ve been ignoring them for a long time.

bonds <- familyoffice::fo_exchange("six.bonds")
stocks <- familyoffice::fo_exchange("six.stocks")
etfs <- familyoffice::fo_exchange("six.etfs") |> 
  mutate(FundLongName = stringi::stri_encode(FundLongName, "latin1", "UTF-8"),
         IssuerNameFull = stringi::stri_encode(IssuerNameFull, "latin1", "UTF-8")) |>
  mutate(FundLongName = iconv(FundLongName, to = "UTF-8"),
         IssuerNameFull = iconv(IssuerNameFull, to = "UTF-8"))

eurchf <- tq_get("EURCHF=X", from = "2010-01-01", to = Sys.Date())
dax <- tq_get("^GDAXI", from = "2010-01-01", to = Sys.Date())
smi <- tq_get("^SSMI", from = "2010-01-01", to = Sys.Date())
bonds_ch <- tq_get("CSBGC0.SW", from = "2010-01-01", to = Sys.Date())
bonds_de <- tq_get("X03G.DE", from = "2010-01-01", to = Sys.Date())

library(familyoffice)

chfbondetf_pricedata <- etfs |>
  filter(AssetClassDesc == "Fixed Income", FundCurrency == "CHF", TradingBaseCurrency == "CHF") |>
  mutate(sixdata = map(ISIN, fo_get, from = as.Date("1900-01-01"), get = "six.pricedividend", type = "fund"))

saveRDS(bonds, "data/bondetfs/bonds.RDS")
saveRDS(stocks, "data/bondetfs/stocks.RDS")
saveRDS(etfs, "data/bondetfs/etfs.RDS")
saveRDS(eurchf, "data/bondetfs/eurchf.RDS")
saveRDS(dax, "data/bondetfs/dax.RDS")
saveRDS(smi, "data/bondetfs/smi.RDS")
saveRDS(bonds_ch, "data/bondetfs/bonds_ch.RDS")
saveRDS(bonds_de, "data/bondetfs/bonds_de.RDS")

saveRDS(chfbondetf_pricedata, "data/bondetfs/chfbondetf_pricedata.RDS")
bonds <- readRDS("data/bondetfs/bonds.RDS")
stocks <- readRDS("data/bondetfs/stocks.RDS")
etfs <- readRDS("data/bondetfs/etfs.RDS")
eurchf <- readRDS("data/bondetfs/eurchf.RDS")
dax <- readRDS("data/bondetfs/dax.RDS")
smi <- readRDS("data/bondetfs/smi.RDS")
bonds_ch <- readRDS("data/bondetfs/bonds_ch.RDS")
bonds_de <- readRDS("data/bondetfs/bonds_de.RDS")

chfbondetf_pricedata <- readRDS("data/bondetfs/chfbondetf_pricedata.RDS")

There are 2’840 bonds listed on the Swiss Exchange (SIX). Wow.

Compare this to only 261 stocks and 2’017 ETFs.

I do no plan to invest in individual bonds directly because same as with picking stocks, we bear idiosyncratic risk. And with bonds the risk is unsymmetric. If a company goes bankrupt, you lose your investment. If the company does well, you only get the coupon payments and the par value at maturity. Therefore, we shall foxus on bond ETFs.

Why Bonds

On the one hand, bonds are less volatile than stocks. There are to some extend uncorrelated to stocks (however, high yield bonds like low grade corporate bonds will still be strongly correlated to stocks). So there’s the diversification benefit.

On the other hand, bonds do good well when interest rates are falling. There’s an inverse relationship between interest rates and bond prices. When interest rates fall, the price of existing bonds rises.

In a zero-coupon bond, the price is in direct relation to the same term interest rate.

\[P = \frac{F}{(1+r)^n}\]

\(F\) is the nominal value that you get back at maturity. The rate \(r\) goes up, the price \(P\) goes down. The rate goes down, the price goes up.

With coupon \(C\) paying bonds, the price is the present value of all future cash flows, which are the coupon payments and the par value at maturity.

\[P = \frac{C}{(1+r)^1} + \frac{C}{(1+r)^2} + \ldots + \frac{C+F}{(1+r)^n}\]

How much the price changes given a change in interest rates is called duration. The higher the duration, the more sensitive the bond price is to interest rate changes. Simplified the modified duration \(D\) is:

\[D = - \frac{\Delta P / P}{\Delta i}\]

Bond ETFs

There are 546 bond ETFs listed on the Swiss Exchange. That’s the largest asset class before developed market equities. Of course, all equity ETFs combied (equity themes, strategy, emerging markets) outnumber the bond ETFs.

etfs |> 
  count(AssetClassDesc, sort = T) |> 
  ggplot(aes(x = reorder(AssetClassDesc, n), y = n, fill = AssetClassDesc)) +
  geom_col() +
  coord_flip() +
  labs(
    title = "Bond ETFs on the Swiss Exchange",
    subtitle = "by Asset Class",
    x = "Asset Class",
    y = "Number of ETFs"
  ) +
  scale_y_continuous(labels = number_format(big.mark = "'")) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        legend.position = "none")

Of the bond ETFs listed on SIX, most are USD or EUR denominated. There are just 56 CHF denominated and traded bond ETFs.

etfs |> 
  filter(AssetClassDesc == "Fixed Income") |> 
  count(FundCurrency, sort = T) |> 
  ggplot(aes(x = reorder(FundCurrency, n), y = n, fill = FundCurrency)) +
  geom_col() +
  coord_flip() +
  labs(
    title = "Bond ETFs on the Swiss Exchange",
    subtitle = "by Fund Currency",
    x = "Fund Currency",
    y = "Number of ETFs"
  ) +
  scale_y_continuous(labels = number_format(big.mark = "'")) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        legend.position = "none")

Currency Risks

Unlike holding stocks, there is no implied price adjustment on then foreign exchange markets move. Say you are a CHF investor and hold foreign stocks and bonds, for example EUR assets. As the CHF moves, the foreign stocks will adjust their prices to reflect the new exchange rate. But bonds are fixed income, so they will not adjust their price. The currency risk is real.

On January 15 2015, the Swiss national bank (SNB) removed the EURCHF floor of 1.20. The EURCHF exchange rate dropped from 1.20 to close to 1.00. How did stocks and bonds react?

assets <- bind_rows(
  eurchf |> mutate(Asset = "EURCHF") |> mutate(adjusted_chf = adjusted),
  dax |> mutate(Asset = "DAX") |> left_join(eurchf |> select(date, eurchf = adjusted)) |> mutate(adjusted_chf = adjusted * eurchf),
  smi |> mutate(Asset = "SMI") |> mutate(adjusted_chf = adjusted),
  bonds_ch |> mutate(Asset = "Bonds CH") |> mutate(adjusted_chf = adjusted),
  bonds_de |> mutate(Asset = "Bonds DE") |> left_join(eurchf |> select(date, eurchf = adjusted)) |> mutate(adjusted_chf = adjusted * eurchf)
) |> 
  group_by(Asset) |>
  mutate(r_chf = adjusted_chf/lag(adjusted_chf)-1) |> 
  filter(year(date) == 2015, month(date) %in% 1:2) |> 
  mutate(p = cumprod(1 + r_chf) * 100)

assets |> 
  ggplot(aes(x = date, y = p, color = factor(Asset, levels = c("EURCHF", "DAX", "SMI", "Bonds CH", "Bonds DE")))) +
  geom_line() +
  geom_vline(xintercept = as.Date("2015-01-15"), linetype = "dashed", color = "red") +
  labs(
    title = "Impact of SNB EURCHF Floor Removal on Assets",
    subtitle = "January 2015, all Assets in CHF",
    x = "Date",
    y = "Price (Index)",
    color = "Asset"
  ) +
  scale_y_continuous(labels = number_format(big.mark = "'")) +
  scale_color_manual(values = c("EURCHF" = "black", "DAX" = "red1", "SMI" = "red4", "Bonds CH" = "blue1", "Bonds DE" = "blue4")) +
  theme(legend.position = "bottom")

Notice how equities (SMI and DAX) dropped simultaneously when converted in CHF as the peg was removed. And the two recovered similarly over the following weeks.

It’s another story with bonds: Swiss CHF-denominated bonds stayed stable (slightly moved up as interest rate expectations fell), while the EUR-denominated bonds dropped significantly from a CHF point of view. And the foreign EUR bonds did not recover as the equities did. This demonstrates the currency risk of foreign bonds.

So what shoud we do? We should focus on CHF-denominated bonds. Or hedge currency the risk.

Bond ETFs in CHF

iShares being the biggest ETF provider has the most CHF-denominated bond ETFs listed on SIX.

etfs |> 
  filter(AssetClassDesc == "Fixed Income", FundCurrency == "CHF", TradingBaseCurrency == "CHF") |> 
  select(FundLongName, IssuerNameFull, FundCurrency, AssetClassDesc, 
         ManagementFee, ReplicationMethodDesc, ManagementStyleDesc, 
         FundUnderlyingDescription, ProductLineDesc,UnderlyingGeographicalDesc) |> 
  count(IssuerNameFull, sort = T) |> 
  ggplot(aes(x = reorder(IssuerNameFull, n), y = n, fill = IssuerNameFull)) +
  geom_col() +
  coord_flip() +
  labs(
    title = "CHF Bond ETFs on the Swiss Exchange",
    subtitle = "by Issuer",
    x = "Issuer",
    y = "Number of ETFs"
  ) +
  scale_y_continuous(labels = number_format(big.mark = "'")) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        legend.position = "none")

Most have annual fees below 0.25%, and a few around or above 0.5%.

etfs |> 
  filter(AssetClassDesc == "Fixed Income", FundCurrency == "CHF", TradingBaseCurrency == "CHF") |> 
  select(FundLongName, IssuerNameFull, FundCurrency, AssetClassDesc, 
         ManagementFee, ReplicationMethodDesc, ManagementStyleDesc, 
         FundUnderlyingDescription, ProductLineDesc,UnderlyingGeographicalDesc) |> 
  ggplot(aes(x = ManagementFee)) +
  geom_histogram(binwidth = 0.0002, position = "dodge") +
  labs(
    title = "CHF Bond ETFs on the Swiss Exchange",
    subtitle = "by Management Fee",
    x = "Management Fee",
    y = "Number of ETFs"
  ) +
  scale_x_continuous(labels = percent_format(accuracy = 0.01, big.mark = "'"), limits = c(0, 0.01)) +
  scale_y_continuous(labels = number_format(big.mark = "'")) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        legend.position = "bottom")

Most physically replicate a bond index, meaning they hold actual bonds. Only one synthetic ETF is listed.

etfs |> 
  filter(AssetClassDesc == "Fixed Income", FundCurrency == "CHF", TradingBaseCurrency == "CHF") |> 
  select(FundLongName, IssuerNameFull, FundCurrency, AssetClassDesc, 
         ManagementFee, ReplicationMethodDesc, ManagementStyleDesc, 
         FundUnderlyingDescription, ProductLineDesc,UnderlyingGeographicalDesc) |> 
  count(ReplicationMethodDesc, sort = T) |> 
  ggplot(aes(x = reorder(ReplicationMethodDesc, n), y = n, fill = ReplicationMethodDesc)) +
  geom_col() +
  coord_flip() +
  labs(
    title = "CHF Bond ETFs on the Swiss Exchange",
    subtitle = "by Replication Method",
    x = "Replication Method",
    y = "Number of ETFs"
  ) +
  scale_y_continuous(labels = number_format(big.mark = "'")) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        legend.position = "none")

Only three CHF bond ETFs are actively managed, the rest are passively managed.

etfs |> 
  filter(AssetClassDesc == "Fixed Income", FundCurrency == "CHF", TradingBaseCurrency == "CHF")|> 
  select(FundLongName, IssuerNameFull, FundCurrency, AssetClassDesc, 
         ManagementFee, ReplicationMethodDesc, ManagementStyleDesc, 
         FundUnderlyingDescription, ProductLineDesc,UnderlyingGeographicalDesc) |> 
  count(ManagementStyleDesc, sort = T) |> 
  ggplot(aes(x = reorder(ManagementStyleDesc, n), y = n, fill = ManagementStyleDesc)) +
  geom_col() +
  coord_flip() +
  labs(
    title = "CHF Bond ETFs on the Swiss Exchange",
    subtitle = "by Management Style",
    x = "Management Style",
    y = "Number of ETFs"
  ) +
  scale_y_continuous(labels = number_format(big.mark = "'")) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        legend.position = "none")

And lastly, most are foreign bond ETFs even though they are denominated in CHF. Only a few are Swiss bonds.

etfs |> 
  filter(AssetClassDesc == "Fixed Income", FundCurrency == "CHF", TradingBaseCurrency == "CHF") |> 
  select(FundLongName, IssuerNameFull, FundCurrency, AssetClassDesc, 
         ManagementFee, ReplicationMethodDesc, ManagementStyleDesc, 
         FundUnderlyingDescription, ProductLineDesc,UnderlyingGeographicalDesc) |> 
  count(UnderlyingGeographicalDesc, sort = T) |> 
  ggplot(aes(x = reorder(UnderlyingGeographicalDesc, n), y = n, fill = UnderlyingGeographicalDesc)) +
  geom_col() +
  coord_flip() +
  labs(
    title = "CHF Bond ETFs on the Swiss Exchange",
    subtitle = "by Underlying Geographical Description",
    x = "Underlying Geographical Description",
    y = "Number of ETFs"
  ) +
  scale_y_continuous(labels = number_format(big.mark = "'")) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        legend.position = "none")

min_dates <- chfbondetf_pricedata |> 
  unnest(sixdata) |>
  group_by(FundLongName, IssuerNameFull, ISIN) |> 
  summarise(start = min(date))

min_dates |> 
  ggplot(aes(x = start)) +
  geom_histogram(binwidth = 365, position = "dodge", color = "grey") +
  labs(
    title = "CHF Bond ETFs on the Swiss Exchange",
    subtitle = "by Start Date",
    x = "Start Date",
    y = "Number of ETFs"
  ) +
  scale_x_date(date_labels = "%Y", date_breaks = "1 year") +
  scale_y_continuous(labels = number_format(big.mark = "'"), breaks = seq(0, 100, 2)) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        legend.position = "bottom")

Return Characteristics

We would expect the returns to be highly correlated.

monthly_rets <- chfbondetf_pricedata |> 
  unnest(sixdata) |> 
  group_by(FundLongName, IssuerNameFull, ISIN) |> 
  mutate(r = adjusted/lag(adjusted)-1) |> 
  filter(date >= as.Date("2021-01-01")) |> 
  filter(n() > 1110) |> 
  filter(!is.na(r)) |> 
  group_by(date = floor_date(date, "month"), add = T) |> 
  summarise(r = prod(1+r)-1) |> 
  ungroup()

p <- monthly_rets |> 
  group_by(FundLongName, IssuerNameFull, ISIN) |> 
  summarise(TR = prod(1+r)-1,
            r_annualized = prod(1+r)^(12/n()) - 1,
            volatility = sd(r, na.rm = TRUE) * sqrt(12),
            SR = r_annualized/volatility) |> 
  ggplot(aes(x = r_annualized, y = volatility, label = FundLongName, color = IssuerNameFull)) +
  geom_point() +
  # geom_text_repel() +
  labs(
    title = "CHF Bond ETFs on the Swiss Exchange",
    subtitle = "Annualized Return vs. Volatility",
    x = "Annualized Return",
    y = "Volatility",
    color = "Issuer"
  ) +
  scale_x_continuous(labels = percent_format(accuracy = 0.01, big.mark = "'")) +
  scale_y_continuous(labels = percent_format(accuracy = 0.01, big.mark = "'")) +
  theme(legend.position = "none")

plotly::ggplotly(p, tooltip = "label")
p <- monthly_rets |> 
  group_by(FundLongName, IssuerNameFull, ISIN) |>
  mutate(p = cumprod(1 + r) * 100) |>
  ggplot(aes(x = date, y = p, color = IssuerNameFull, group = FundLongName, label = FundLongName)) +
  geom_line() +
  labs(
    title = "CHF Bond ETFs on the Swiss Exchange",
    subtitle = "Cumulative Returns since 2021",
    x = "Date",
    y = "Cumulative Return (Index)",
    color = "Issuer"
  ) +
  scale_y_continuous(labels = number_format(big.mark = "'")) +
  # scale_color_brewer(palette = "Set1") +
  theme(legend.position = "none")

plotly::ggplotly(p, tooltip = "label")

Swiss CHF Bonds

swiss_funds <- etfs |> 
  filter(AssetClassDesc == "Fixed Income", FundCurrency == "CHF", TradingBaseCurrency == "CHF",
         UnderlyingGeographicalDesc == "Switzerland") |> 
  select(FundLongName, ISIN)

swiss_funds |> 
  DT::datatable(
    options = list(pageLength = 10, autoWidth = TRUE, scrollX = TRUE, dom = "t"),
    rownames = FALSE,
    colnames = c("Fund Name", "ISIN")
  )
chfbondetf_pricedata |> 
  filter(ISIN %in% swiss_funds$ISIN) |> 
  unnest(sixdata) |>
  ggplot(aes(x = date, color = FundLongName)) +
  geom_line(aes(y = adjusted, color = "adjusted")) +
  geom_line(aes(y = close, color = "close")) +
  labs(
    title = "Swiss CHF Bond ETFs on the Swiss Exchange",
    subtitle = "Price Data",
    x = "Date",
    y = "Price",
    color = "Fund"
  ) +
  scale_y_continuous(labels = number_format(big.mark = "'")) +
  scale_color_manual(values = c("adjusted" = "black", "close" = "red")) +
  theme(legend.position = "right") +
  facet_wrap(~FundLongName, ncol = 2)

So should I invest in bond ETFs as a relatively young investor?

Pro arguments:

The last point would be the most appealing to me. If we were in a high interest rate environment, investing in bonds can result in gains as interest rates fall. This has been the case since 2024. See below:

chfbondetf_pricedata |> 
  filter(ISIN %in% swiss_funds$ISIN) |> 
  unnest(sixdata) |>
  group_by(FundLongName, IssuerNameFull, ISIN) |>
  mutate(r = adjusted/lag(adjusted)-1) |>
  filter(date >= as.Date("2024-01-01")) |>
  mutate(p = cumprod(1 + r) * 100) |>
  ggplot(aes(x = date, color = FundLongName)) +
  geom_line(aes(y = p, color = "total performance")) +
  labs(
    title = "Swiss CHF Bond ETFs on the Swiss Exchange",
    subtitle = "Price Data",
    x = "Date",
    y = "Performance Indexed",
    color = "Fund"
  ) +
  scale_y_continuous(labels = number_format(big.mark = "'")) +
  scale_color_manual(values = c("adjusted" = "black", "close" = "red")) +
  theme(legend.position = "right") +
  facet_wrap(~FundLongName, ncol = 2)

Contra arguments: