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)
Listed Swiss Immofunds (Immobilienfonds) can be thought of as Swiss REIT equivalents. They are investment vehicles that directly invest in real estate properties. As such they are tax exempt for the holder. This means the private investor holding does not have to pay taxes on received dividends, nor on the value (wealth tax; the declarable tax-value is generally greatly reduced making).
The funds differ and exhibit different risk profiles. These may be driven by:
Currently there are 49 listed real estate funds traded on the Swiss stock exchange SIX. 47 are traded in CHF.
Some funds are traded at a premium (Agio), some at a discount (Disagio) to their net asset value (NAV). The NAV is calculated by the fund management according to common valuation methods and published in semi-annual reports.
Previously, Credit Suisse published statistics on immofunds (Schweizer Immobilienfonds Kennzahlen) on a monthly basis and freely available on their website. Since the merge with UBS, there does not seem to be a replacement for this service. Finding NAV is a bit of a hassle, as it is not available freely in a single place. One can get them from professional providers (Bloomberg, etc.), or from the funds’ reports. The SIX fund explorer provides some information, but not the NAV.
We download the price data for all 47 CHF-demoninated immofunds from SIX.
library(familyoffice)
sixfunds <- familyoffice::fo_exchange("six.mutual")
immofunds <- sixfunds |>
filter(AssetClassDesc == "Real Estate",
UnderlyingGeographicalDesc == "Switzerland",
TradingBaseCurrency == "CHF", FundCurrency == "CHF") |>
select(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull, ManagementFee)
immofunds_pricedata <- immofunds |>
mutate(sixdata = map(ISIN, fo_get, from = as.Date("1900-01-01"), get = "six.pricedividend", type = "fund"))
immofunds_pricedata |>
saveRDS("siximmofunds/immofunds_pricedividenddata.RDS")
immofunds_pricedata <- readRDS("siximmofunds/immofunds_pricedividenddata.RDS") |>
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"))
taxvalues <- read_csv("siximmofunds/taxvalues.csv")
We’re only interested in immofunds that are largely tax-free for the private investor. That’s the case when they hold properties directly. We therefore downloaded the tax values as per 31.12.2024 from the course listings of the Federal tax administration (ESTV).
tax <- immofunds_pricedata |>
# get price per 31.12.2024 from each
unnest(sixdata) |>
filter(year(date) == 2024) |>
group_by(FundLongName, ISIN, ValorSymbol, ValorNumber, IssuerNameFull) |>
mutate(dividend2024 = sum(dividend.value, na.rm = TRUE)) |>
slice_max(date) |>
ungroup() |>
select(FundLongName, ISIN, ValorSymbol, ValorNumber, IssuerNameFull, close2024 = close, dividend2024) |>
left_join(taxvalues, by = c("ISIN")) |>
mutate(taxV = Steuerwert/close2024,
tavD = ErtragSteuerbar/dividend2024)
tax |>
arrange(taxV) |>
transmute(FundLongName, IssuerNameFull, ISIN,
TaxValue = percent(taxV, accuracy = 0.1),
TaxDividend = percent(tavD, accuracy = 0.1)) |>
DT::datatable(options = list(pageLength = 10, autoWidth = TRUE,
dom = 'tip'),
rownames = FALSE,
caption = "Percent of actual value and dividend to be declared for tax purposes as per 31.12.2024")
So we’re left with 26 funds that are largely tax free for the private investor.
immofunds_pricedata26 <- tax |>
filter(tavD < 0.05, taxV < 0.25) |>
select(ISIN) |>
left_join(immofunds_pricedata,
by = "ISIN")
Probably the most well known and largest fund is the UBS Sima fund. The Sima fund invests via a subsidiary in real estate companies, i.e. it does not hold properties directly. This means that the fund is not tax exempt for the private investor and we exclude it from our analysis.
We want funds that have been around for a while, so that we can analyze their performance.
around <- immofunds_pricedata26 |>
unnest(sixdata) |>
group_by(FundLongName, IssuerNameFull, ISIN) |>
summarise(start = min(date)) |>
arrange(start)
around |>
mutate(start = format(start, "%d.%m.%Y")) |>
DT::datatable(options = list(pageLength = 10, autoWidth = TRUE,
dom = 'tip'),
rownames = FALSE,
caption = "Immofunds available since")
Some funds are available since end of 1997, some of the well knowns like Bonhote or Realstone came later (2007, 2010). The most recent additions is the Helvetia fund.
We shall focus on funds that have been around since at least end of 2020. We end up with 20 funds.
immofunds_pricedata20 <- immofunds_pricedata26 |>
unnest(sixdata) |>
group_by(ISIN, FundLongName, ValorSymbol, ValorNumber, IssuerNameFull, ManagementFee) |>
filter(any(date <= as.Date("2020-12-31"))) |>
ungroup()
As stated above, the agio (premium) or disagio (discount) is the market price to the NAV. It’s an important driver of the performance of the funds. We downloaded NAV data from Bloomberg for the 20 funds.
library(readxl)
# Convert number to Excel-style column label
num_to_excel_col <- function(n) {
col <- ""
while (n > 0) {
remainder <- (n - 1) %% 26
col <- paste0(LETTERS[remainder + 1], col)
n <- (n - 1) %/% 26
}
col
}
all_data <- tibble()
for (i in 1:20) {
col_start <- 1 + (i - 1) * 6
col_label <- num_to_excel_col(col_start + 1)
fund_cell <- paste0(col_label, "6")
# Read fund name from single cell
fund <- read_excel("siximmofunds/fund_premium.xlsx",
sheet = "Premium-value",
range = fund_cell,
col_names = FALSE) |>
pull()
# Data range: 4 columns starting at col_start (e.g., B:E, H:K, etc.)
col_end_data <- num_to_excel_col(col_start + 4)
col_start_data <- num_to_excel_col(col_start)
data_range <- paste0(col_start_data, "9:", col_end_data, "200")
nav <- read_excel("siximmofunds/fund_premium.xlsx",
sheet = "Premium-value",
range = data_range,
col_names = c("date", "premium", "price", "nav", "manual"),
col_types = c("date", "numeric", "numeric", "numeric", "numeric"),
na = "#N/A N/A")
nav <- nav |>
mutate(fund = fund)
all_data <- bind_rows(all_data, nav)
}
navs <- all_data |>
filter(!is.na(nav)) |>
mutate(ValorSymbol = str_remove(fund, " SW Equity")) |>
mutate(premium = premium/100) |>
select(date, premium, price, nav, ValorSymbol) |>
mutate(date = as.Date(date))
navs |> saveRDS("siximmofunds/navs.RDS")
navs <- readRDS("siximmofunds/navs.RDS") |>
mutate(ValorSymbol = str_replace(ValorSymbol, "SOL61", "SOL"))
all_agios <- immofunds_pricedata20 |>
full_join(navs, by = c("ValorSymbol", "date")) |>
arrange(ValorSymbol, date) |>
group_by(ValorSymbol) |>
mutate(FundLongName = na.locf0(FundLongName),
ISIN = na.locf0(ISIN),
IssuerNameFull = na.locf0(IssuerNameFull)) |>
filter(!is.na(ISIN)) |>
group_by(ValorSymbol, FundLongName, ISIN, IssuerNameFull) |>
mutate(nav_last = na.locf0(nav)) |>
filter(!is.na(nav_last)) |>
mutate(agio = adjusted/nav_last-1) |>
filter(agio < 1.5,
agio > -0.5)
mean_agio <- all_agios |>
group_by(date) |>
summarise(agio = mean(agio, na.rm = TRUE)) |>
ungroup()
all_agios |>
ggplot(aes(x = date, y = agio)) +
geom_line(aes(color = ValorSymbol), alpha = 0.7) +
geom_line(data = mean_agio, aes(x = date, y = agio), color = "black", size = 1.2) +
geom_hline(yintercept = 0, linetype = "dashed", color = "gray") +
labs(x = NULL, y = "Agio (premium) to NAV",
title = "Immofunds: Agio (premium) to NAV",
subtitle = "Data from SIX and Bloomberg") +
scale_y_continuous(labels = scales::percent_format(accuracy = 1), breaks = seq(-0.5, 1.5, by = 0.1)) +
scale_x_date(date_labels = "%Y", date_breaks = "1 year") +
theme(legend.position = "none")
We observe that there’s clear correlation between the agios of the funds. Starting 2022 all of the funds’ agios dropped significantly and recovered since 2024. Currently all agios are positive. This 2022-2024 period overlaps with yield hikes worldwide and in Switzerland.
It is to note that the NAV are reported semi-annually. To obtain a daily agio, we use the last reported NAV for the period until the next NAV is reported. This is not ideal as the NAVs might be outdated, but it is the best we can do with the available data.
Below is are the total returns since December 2020.
immofunds_rets20 <- immofunds_pricedata20 |>
full_join(navs, by = c("ValorSymbol", "date")) |>
arrange(ValorSymbol, date) |>
mutate(nav_last = na.locf0(nav)) |>
filter(!is.na(nav_last)) |>
mutate(agio = adjusted/nav_last-1) |>
group_by(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull) |>
mutate(r_cl = close/lag(close)-1,
r_adj = adjusted/lag(adjusted)-1,
r_agio = (agio+1)/(lag(agio)+1)-1) |>
filter(!is.na(r_cl), !is.na(r_adj)) |>
group_by(date = floor_date(date, "months"),
.add = T) |>
summarise(r_cl = prod(1+r_cl, na.rm = TRUE) - 1,
r_adj = prod(1+r_adj, na.rm = TRUE) - 1,
r_agio = prod(1+r_agio, na.rm = TRUE) - 1,
agio = last(agio)) |>
ungroup()
immofunds_rets20 |>
group_by(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull) |>
group_by(year = year(date), .add = T) |>
summarise(TR = prod(1+r_adj)-1,
r_agio = prod(1+r_agio)-1) |>
ungroup() |>
filter(year >= 2021) |>
ggplot(aes(x = TR, y = ValorSymbol)) +
geom_point(aes(color = "TR"), alpha = 0.9) +
geom_point(aes(x = r_agio, color = "change\nin agio"), alpha = 0.9) +
geom_vline(xintercept = 0, linetype = "dashed", color = "gray") +
scale_x_continuous(labels = scales::percent_format(accuracy = 1)) +
scale_y_discrete(limits = rev) +
scale_color_manual(values = c("TR" = "red", "change\nin agio" = "grey")) +
labs(x = "Total Return (TR) and change in Agio (premium) to NAV",
y = NULL,
title = "Immofunds: Total Return (TR) and change in Agio (premium) to NAV",
subtitle = "Annualized returns since end of 2020",
color = NULL) +
facet_wrap(~ year) +
theme(legend.position = c(0.8, 0.2))
Of course the TR are generally above the return from change in premiums. The agios are about on the level of 2020 again.
To better understand the difference of agios between funds, we can try a trading strategy. I.e. we would invest in the lowest agio funds every month.
strat100 <- immofunds_rets20 |>
group_by(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull) |>
mutate(agio0 = lag(agio)) |>
drop_na() |>
filter(date >= as.Date("2010-01-01")) |>
group_by(date) |>
summarise(r_adj = mean(r_adj))
strat75 <- immofunds_rets20 |>
group_by(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull) |>
mutate(agio0 = lag(agio)) |>
drop_na() |>
filter(date >= as.Date("2010-01-01")) |>
group_by(date) |>
slice_min(agio, prop = 0.75) |>
summarise(r_adj = mean(r_adj))
strat50 <- immofunds_rets20 |>
group_by(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull) |>
mutate(agio0 = lag(agio)) |>
drop_na() |>
filter(date >= as.Date("2010-01-01")) |>
group_by(date) |>
slice_min(agio, prop = 0.5) |>
summarise(r_adj = mean(r_adj))
strat25 <- immofunds_rets20 |>
group_by(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull) |>
mutate(agio0 = lag(agio)) |>
drop_na() |>
filter(date >= as.Date("2010-01-01")) |>
group_by(date) |>
slice_min(agio0, prop = 0.25) |>
summarise(r_adj = mean(r_adj))
strategies <- bind_rows(strat100 |> mutate(strategy = "all"),
strat75 |> mutate(strategy = "75% lowest agio"),
strat50 |> mutate(strategy = "50% lowest agio"),
strat25 |> mutate(strategy = "25% lowest agio"))
strategies5y <- strategies |>
mutate(period = floor_date(date, "5 years"))
strategies5y |>
group_by(period = paste0(year(period), "-", year(period)+4), strategy) |>
summarise(TR = prod(1+r_adj)-1,
r_annualized = prod(1+r_adj)^(12/n()) - 1,
volatility = sd(r_adj, na.rm = TRUE) * sqrt(12),
SR = r_annualized/volatility) |>
mutate(across(c(TR, r_annualized, volatility), percent, accuracy = 0.1)) |>
mutate(across(c(SR), round, digits = 2)) |>
DT::datatable(options = list(pageLength = 12, autoWidth = TRUE,
dom = 't'),
rownames = FALSE,
caption = "Immofunds strategies based on agio (premium) to NAV",
colnames = c("Strategy", "Total Return (TR)", "Annualized Return", "Volatility", "Sharpe Ratio"))
strategies5y |>
group_by(period = paste0(year(period), "-", year(period)+4), strategy) |>
summarise(TR = prod(1+r_adj)-1,
r_annualized = prod(1+r_adj)^(12/n()) - 1,
volatility = sd(r_adj, na.rm = TRUE) * sqrt(12),
SR = r_annualized/volatility) |>
ggplot(aes(x = period, y = r_annualized, color = strategy)) +
geom_line(size = 1) +
geom_point(size = 2) +
geom_hline(yintercept = 0, linetype = "dashed", color = "gray") +
scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
scale_color_manual(values = c("all" = "black",
"75% lowest agio" = "red4",
"50% lowest agio" = "red3",
"25% lowest agio" = "red1")) +
labs(x = "Period", y = "Annualized Return",
title = "Immofunds: Annualized Returns of Agio Strategies",
subtitle = "Returns since 2010",
color = "Strategy") +
theme(legend.position = "bottom",
axis.text.x = element_text(angle = 45, hjust = 1))
We observe that in 2010-2014 and 2020-2024, the investing in the 25% lowest agio funds performed best. On the other hand, 2015-2019 and the current year 2025, the strategy of investing in all funds outperformed hunting for the lowest agios.
It seems like in periods with rising agio levels (usually accompanied by lowering interest rates), the strategy of investing in all funds works best, while in periods with falling agios (usually accompanied by rising interest rates), hunting for the lowest agios works better.
The probable explanation is that agios are mean-reverting.
immofunds_rets20 |>
filter(date >= as.Date("2021-01-01")) |>
group_by(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull) |>
summarise(TR = prod(1+r_adj)-1,
r_annualized = prod(1+r_adj)^(12/n()) - 1,
volatility = sd(r_adj, na.rm = TRUE) * sqrt(12),
SR = r_annualized/volatility) |>
mutate(across(c(TR, r_annualized, volatility), percent, accuracy = 0.1)) |>
mutate(across(c(SR), round, digits = 2)) |>
ungroup() |>
select(FundLongName, IssuerNameFull, ISIN,
TR, r_annualized, volatility, SR) |>
arrange(desc(r_annualized)) |>
DT::datatable(options = list(pageLength = 10, autoWidth = TRUE,
dom = 'tip'),
rownames = FALSE,
caption = "Immofunds performance since 2021",
colnames = c("Fund", "Issuer", "ISIN",
"Total Return (TR)", "Annualized Return", "Volatility", "Sharpe Ratio"))
We observe that the best performing funds with 10%+ annualized returns (Residentia, Direct Hospitality, both from UBS) also showed elevated volatility (around 22%, and 19% respectively).
Overall UBS has the most funds (7, also due to the merger with Credit Suisse?). The Residentia, Direct Hospitality, and LivingPlus are announced to be merged into a single fund in 2025.
While two UBS funds lead the TR ranking, three of their funds come last in the ranking with a negative annualized TR since 2021: Direct LivingPlus, Direct Green Property, and Direct LogisticsPlus.
To understand the drivers of the performance of the funds, we can apply a principal component analysis (PCA) to the returns of the funds. This will help us identify the main factors that explain the variance in the returns.
We would expect that the first principal component (PC1) explains the most variance in the returns driven by general real estate market movements, while the second and third principal component might capture sector-specific or geographical factors.
immofunds_pca <- immofunds_rets20 |>
filter(date >= as.Date("2021-01-01")) |>
select(date, ValorSymbol, r = r_adj) |>
pivot_wider(names_from = ValorSymbol, values_from = r)
pca <- prcomp(immofunds_pca |> select(-date), center = TRUE, scale. = TRUE)
library(factoextra)
pca |> fviz_eig()
library(factoextra)
pca |> fviz_pca_biplot()
rotations <- pca$rotation |>
as.data.frame() |>
rownames_to_column("ValorSymbol") |>
arrange(desc(PC1)) |>
left_join(immofunds_pricedata20 |>
distinct(ValorSymbol, FundLongName, IssuerNameFull, ISIN),
by = "ValorSymbol") |>
select(ValorSymbol, FundLongName, IssuerNameFull, ISIN, PC1, PC2, PC3, PC4)
rotations |>
pivot_longer(cols = c(PC1, PC2, PC3, PC4), names_to = "PC", values_to = "value") |>
ggplot(aes(x = ValorSymbol, y = value)) +
geom_col(aes(fill = PC), position = "dodge") +
coord_flip() +
facet_wrap(~ PC, scales = "free_y") +
labs(x = NULL, y = "Rotation") +
theme(legend.position = "none")
What are the funds even doing? I had ChatGPT 4o run a deep research to get a short profile text based on investor documents that can be found online. I also asked the model to classify on four metrics:
Here’s what I got:
profiles <- read_csv("siximmofunds/swiss_real_estate_fund_profiles_classified.csv")
profiles |>
select(`Fund Name`, Symbol, `Property Type`, `Geographic Focus`, `Strategic Focus`) |>
arrange(`Property Type`, `Geographic Focus`, `Strategic Focus`) |>
DT::datatable(options = list(pageLength = 10, autoWidth = TRUE,
dom = 'tip'),
rownames = FALSE,
caption = "Immofunds profiles and classifications",
colnames = c("Fund Name", "Symbol", "Property Type", "Geographical Focus", "Strategic Focus"))
We revisit the PCA rotations to see if we see patterns beyond the first principal component.
rotations |>
left_join(profiles |>
select(Symbol, `Property Type`, `Geographic Focus`, `Strategic Focus`),
by = c("ValorSymbol" = "Symbol")) |>
ggplot(aes(x = PC2, y = PC3, color = `Property Type`)) +
geom_point(size = 3) +
geom_text_repel(aes(label = ValorSymbol), size = 3, max.overlaps = 20) +
labs(x = "PC2", y = "PC3",
title = "PCA of Immofunds Returns",
subtitle = "Colored by Property Type")
Seems like Residential score higher on the PC2. With PC3 the picture is less clear.
rotations_class <- rotations |>
left_join(profiles |>
select(Symbol, `Property Type`, `Geographic Focus`, `Strategic Focus`),
by = c("ValorSymbol" = "Symbol"))
rotations_class |>
group_by(`Property Type`) |>
summarise(n = n(),
PC1 = mean(PC1, na.rm = TRUE),
PC2 = mean(PC2, na.rm = TRUE),
PC3 = mean(PC3, na.rm = TRUE)) |>
# 2 digits
mutate(across(c(PC1, PC2, PC3), round, digits = 2)) |>
DT::datatable(options = list(pageLength = 10, autoWidth = TRUE,
dom = 't'),
rownames = FALSE,
caption = "PCA Rotations by Property Type",
colnames = c("Property Type", "Count", "PC1", "PC2", "PC3"))
Profile texts (sourced with ChatGPT):
profiles |>
select(`Fund Name`, Symbol, Profile) |>
arrange(Symbol) |>
DT::datatable(options = list(pageLength = 10, autoWidth = TRUE,
dom = 'tip'),
rownames = FALSE,
caption = "Immofunds Profiles",
colnames = c("Fund Name", "Symbol", "Profile"))
library(readxl)
saron <- read_excel("siximmofunds/snb-chart-data-zimomach-de-all-20250602_1430.xlsx",
skip = 15) |>
transmute(date = as.Date(`...1`), saron = `SARON Handelsschluss`/100) |>
drop_na()
saron |>
ggplot(aes(x = date, y = saron)) +
geom_line(color = "blue") +
labs(x = NULL, y = "SARON",
title = "SARON (Swiss Average Rate Overnight)",
subtitle = "Data from SNB") +
scale_y_continuous(labels = scales::percent_format(accuracy = 0.1)) +
scale_x_date(date_labels = "%Y", date_breaks = "2 year")
saron_monthly <- saron |>
arrange(date) |>
group_by(date = floor_date(date, "months")) |>
summarise(saron = last(saron)) |>
ungroup()
interest_sensitivities <- immofunds_rets20 |>
left_join(saron_monthly,
by = "date") |>
group_by(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull) |>
mutate(saron_chg = saron - lag(saron)) |>
summarise(sensitivity = cor(r_adj, saron_chg, use = "pairwise.complete.obs")) |>
ungroup()
interest_sensitivities |>
select(-ValorNumber) |>
arrange(sensitivity) |>
mutate(sensitivity = round(sensitivity, 2)) |>
DT::datatable(options = list(pageLength = 10, autoWidth = TRUE,
dom = 'tip'),
rownames = FALSE,
caption = "Interest Rate Sensitivity of Immofunds",
colnames = c("Fund Name", "Symbol", "ISIN", "Issuer", "Sensitivity"))
What does this mean? Let’s compare two of the extremes:
immofunds_rets20 |>
left_join(saron_monthly,
by = "date") |>
filter(ValorSymbol %in% c("RSF", "CSLP")) |>
filter(date >= as.Date("2010-02-01")) |>
group_by(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull) |>
mutate(p = cumprod(1+r_adj)*100) |>
ggplot(aes(x = date, y = p, color = ValorSymbol)) +
geom_line(size = 1) +
geom_line(aes(y = saron*5000, color = "SARON"), size = 1) +
scale_y_continuous(labels = scales::number_format(scale = 1/100, accuracy = 0.5),
sec.axis = sec_axis(~./5000, name = "SARON")) +
labs(x = NULL, y = "Price (in CHF)")
immofunds_rets20 |>
left_join(saron_monthly,
by = "date") |>
filter(ValorSymbol %in% c("RSF", "CSLP")) |>
group_by(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull) |>
mutate(saron_chg = saron - lag(saron)) |>
filter(date >= as.Date("2010-02-01")) |>
ggplot(aes(x = saron_chg, y = r_adj, color = ValorSymbol)) +
geom_point(size = 2, alpha = 0.5) +
geom_smooth(method = "lm", se = FALSE, size = 1) +
scale_x_continuous(labels = scales::percent_format(accuracy = 0.1)) +
scale_y_continuous(labels = scales::percent_format(accuracy = 0.1)) +
labs(x = "SARON", y = "Return",
title = "Immofunds Returns vs SARON",
subtitle = "Data from SIX and SNB",
color = "Fund") +
theme(legend.position = "bottom")
How much of the returns can be explained by the market and the interest rate? How much does it even matter which fund we choose?
library(broom)
mean_rets <- immofunds_rets20 |>
group_by(date) |>
summarise(r_adj_mkt = mean(r_adj))
data <- immofunds_rets20 |>
left_join(saron_monthly, by = "date") |>
select(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull,
r_adj, saron, date) |>
group_by(FundLongName, ValorSymbol, ValorNumber, ISIN, IssuerNameFull) |>
mutate(saron_chg = saron - lag(saron)) |>
left_join(mean_rets, by = "date") |>
filter(!is.na(saron_chg), !is.na(r_adj_mkt)) |>
select(-saron)
out <- data %>%
filter(date >= as.Date("2020-01-01")) |>
nest(data = c(date, r_adj, saron_chg, r_adj_mkt)) %>%
mutate(
model = map(data, ~ lm(r_adj ~ saron_chg + r_adj_mkt, data = .x)),
tidy = map(model, tidy),
glance = map(model, glance),
alpha = map_dbl(tidy, ~ .x$estimate[.x$term == "(Intercept)"]),
beta_saron = map_dbl(tidy, ~ .x$estimate[.x$term == "saron_chg"]),
beta_mkt = map_dbl(tidy, ~ .x$estimate[.x$term == "r_adj_mkt"]),
adj_r2 = map_dbl(glance, ~ .x$adj.r.squared)
) %>%
select(ValorSymbol, alpha, beta_saron, beta_mkt, adj_r2) |>
ungroup()
out |>
select(FundLongName, alpha, beta_saron, beta_mkt, adj_r2) |>
mutate(across(c(alpha, beta_saron, beta_mkt), number, accuracy = 0.001)) |>
arrange(adj_r2) |>
mutate(adj_r2 = percent(adj_r2, accuracy = 0.1)) |>
DT::datatable(options = list(pageLength = 10, autoWidth = TRUE,
dom = 'tip'),
rownames = FALSE,
caption = "Immofunds Factor Relevance (since 2020-01-01)",
colnames = c("Fund Name", "Alpha", "Beta SARON", "Beta Market", "Adjusted R-squared"))
Higher Adjusted R-squared means that the returns of the fund can be better explained by the market and the interest rate. The low R-squares funds might be driven by other factors or unsystematic (meaning fund specific) risk.
The low R-square funds might exhibit stronger under- or outperformance. But we can’t know in advance.
out |>
ggplot(aes(x = adj_r2, y = alpha)) +
geom_point() +
geom_text_repel(aes(label = FundLongName), size = 3, max.overlaps = 20, alpha = 0.3) +
geom_hline(yintercept = 0, linetype = "dashed", color = "gray") +
labs(x = "Adjusted R-squared", y = "Alpha",
title = "Immofunds Factor Relevance",
subtitle = "Data from SIX and SNB") +
scale_x_continuous(labels = scales::percent_format(accuracy = 0.1)) +
scale_y_continuous(labels = scales::number_format(accuracy = 0.001)) +
theme(legend.position = "none")
So as expected we observe thsat lower R-squared funds are also the ones with wider spreads in alpha.
So what should I invest in?
So far we’ve filtered early and were left with 20 funds.
comp_table <- all_agios |>
slice_max(date) |>
select(FundLongName, ValorSymbol, ISIN, IssuerNameFull, agio) |>
left_join(profiles |>
select(Symbol, `Property Type`, `Geographic Focus`, `Strategic Focus`),
by = c("ValorSymbol" = "Symbol")) |>
left_join(out |>
select(ValorSymbol, beta_saron, beta_mkt, adj_r2),
by = c("ValorSymbol")) |>
mutate(points = case_when(
`Property Type` == "Mixed" ~ 3,
`Property Type` %in% c("Residential", "Commercial") ~ 2,
TRUE ~ 0
) + case_when(
`Geographic Focus` %in% c("Nationwide", "Urban Centers") ~ 3,
TRUE ~ 0
) + case_when(
agio < 0.25 ~ 3,
agio < 0.4 ~ 2,
TRUE ~ 0
) + case_when(
adj_r2 > 0.5 ~ 3,
adj_r2 > 0.6 ~ 2,
TRUE ~ 0
))
comp_table |>
arrange(desc(points)) |>
select(-points) |>
DT::datatable(options = list(pageLength = 10, autoWidth = TRUE,
dom = 'tip'),
rownames = FALSE,
caption = "Immofunds Comparison Table",
colnames = c("Fund Name", "Symbol", "ISIN", "Issuer", "Agio (premium)",
"Property Type", "Geographic Focus", "Strategic Focus",
"Beta SARON", "Beta Market", "Adjusted R-squared")) |>
# mark green the ones with `Property Type` == "Mixed", mark yellow the ones with `Property Type` == "Residential" or "Commercial"
formatStyle(columns = "Property Type",
backgroundColor = styleEqual(c("Mixed", "Residential", "Commercial"),
c("lightgreen", "lightyellow", "lightyellow"))) |>
# mark green the ones with `Geographic Focus` == "Nationwide" or "Urban Centers"
formatStyle(columns = "Geographic Focus",
backgroundColor = styleEqual(c("Nationwide", "Urban Centers"),
c("lightgreen", "lightgreen"))) |>
# mark green the ones with agio < 0.25, and yellow those with agio < 0.4
formatStyle(columns = "agio",
backgroundColor = styleInterval(c(0.25, 0.4),
c("lightgreen", "lightyellow", "white"))) |>
# mark green the ones with adj_r2 > 0.5, and yellow those with adj_r2 > 0.5
formatStyle(columns = "adj_r2",
backgroundColor = styleInterval(c(0.5, 0.6),
c("white", "lightyellow", "lightgreen"))) |>
# make agio percent
formatPercentage(columns = "agio", digits = 1) |>
formatPercentage(columns = "adj_r2", digits = 1) |>
# make beta_saron and beta_mkt number with 3 digits
formatRound(columns = c("beta_saron", "beta_mkt"), digits = 3)
Given my criteria, the selection is narrowed to three funds:
Profiles again for the three:
A juicy dividend is nice to have. So out of the three, which one pays the most?
immofunds_pricedata20 |>
group_by(ValorSymbol, FundLongName, ValorNumber, ISIN, IssuerNameFull,
year = year(date)) |>
summarise(dividend = sum(dividend.value, na.rm = T),
median_close = median(close, na.rm = T)) |>
mutate(yield = dividend/median_close) |>
ungroup() |>
filter(year %in% 2021:2024) |>
select(ValorSymbol, FundLongName, ISIN, IssuerNameFull, year, yield) |>
pivot_wider(names_from = year, values_from = yield) |>
arrange(desc(`2024`)) |>
select(FundLongName, ValorSymbol, ISIN, IssuerNameFull,
`2021`, `2022`, `2023`, `2024`) |>
mutate(across(`2021`:`2024`, percent, accuracy = 0.1)) |>
DT::datatable(options = list(pageLength = 10, autoWidth = TRUE,
dom = 'tip'),
rownames = FALSE,
caption = "Immofunds Dividends",
colnames = c("Fund Name", "Symbol", "ISIN", "Issuer",
"2021 Yield", "2022 Yield", "2023 Yield", "2024 Yield")) |>
# yellow the rows with `ValorSymbol` in c("GREEN", "SLREFS", "RSF")
formatStyle(columns = "ValorSymbol",
backgroundColor = styleEqual(c("GREEN", "SLREFS", "RSF"),
c("lightyellow", "lightyellow", "lightyellow")))
We saw anything from 4.1% to 1.8% dividend yield in 2024.
I decided to purchase the Realstone Swiss Property Fund (RSF).
all_agios |>
filter(ValorSymbol == "RSF") |>
ggplot(aes(x = date, y = agio)) +
geom_line(color = "blue", size = 1) +
geom_hline(yintercept = 0, linetype = "dashed", color = "gray") +
labs(x = NULL, y = "Agio (premium) to NAV",
title = "Realstone Swiss Property Fund (RSF): Agio (premium) to NAV",
subtitle = "Data from SIX and Bloomberg") +
scale_y_continuous(labels = scales::percent_format(accuracy = 1), breaks = seq(-0.5, 1.5, by = 0.1)) +
scale_x_date(date_labels = "%Y", date_breaks = "1 year") +
theme(legend.position = "none")
immofunds_rets20 |>
filter(ValorSymbol == "RSF") |>
mutate(p_cl = cumprod(1+r_cl)*100,
p_adj = cumprod(1+r_adj)*100) |>
ggplot(aes(x = date)) +
geom_line(aes(y = p_cl, color = "price performance"), size = 1) +
geom_line(aes(y = p_adj, color = "adjusted performance"), size = 1) +
geom_hline(yintercept = 100, linetype = "dashed", color = "gray") +
scale_y_continuous(labels = scales::number_format(scale = 1/100, accuracy = 0.5)) +
labs(x = NULL, y = "Price (in CHF)",
title = "Realstone Swiss Property Fund (RSF): Price and Adjusted Price",
subtitle = "Data from SIX and Bloomberg",
color = NULL)