UK Dividend

Portfolio Backtest

Published

July 6, 2026

Overview

This report analyses a 15-stock UK dividend portfolio against the FTSE 100 and S&P 500: composition, trailing and calendar-year performance, risk (volatility, drawdown, up/down capture, holdings correlation), historical stress periods, per-stock returns, and dividend yield. This report uses portfolioBacktest package.

All data acquisition and calculation lives in Data & Backtest below, with code folded by default, expand any chunk with Show code to see exactly how a figure was produced. Every section after that only displays results already computed there, so the report can be read results-first, in the order shown in the table of contents.

Data & Backtest

This section contains all data acquisition, cleaning, and calculation code for the report. It runs once, top to bottom; every section that follows only formats and displays objects built here.

Portfolio Definition

The 15-stock target portfolio and its sector classification are defined once and reused throughout the report.

Show code
uk_tickers <- c("RR.L", "LLOY.L", "RIO.L", "STAN.L", "HSBA.L", "AZN.L", "DPLM.L",
                "NG.L", "AIBG.L", "ANTO.L", "ADM.L", "HLMA.L", "MNG.L", "BA.L", "LGEN.L")

w_target <- c(0.10, 0.10, 0.10, 0.10, 0.10, 0.05, 0.05,
              0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05)
names(w_target) <- uk_tickers

# single source of truth for company name / sector, reused by the Investments
# table, the sector donut chart, Key Observations, and the correlation table
company_info <- data.frame(
  Ticker = uk_tickers,
  Name = c("Rolls-Royce Holdings", "Lloyds Banking Group", "Rio Tinto", "Standard Chartered",
           "HSBC Holdings", "AstraZeneca", "Diploma", "National Grid", "AIB Group",
           "Antofagasta", "Admiral Group", "Halma", "M&G", "BAE Systems", "Legal & General"),
  Sector = c("Industrials", "Financials", "Materials", "Financials", "Financials",
             "Health Care", "Industrials", "Utilities", "Financials", "Materials",
             "Financials", "Industrials", "Financials", "Industrials", "Financials"),
  stringsAsFactors = FALSE
) |> mutate(Weight = as.numeric(w_target[Ticker]))

sector_weights <- company_info |>
  group_by(Sector) |>
  summarise(Weight = sum(Weight), .groups = "drop")

Acquisition

Prices are downloaded once via stockDataDownload() and cached locally in ./data, so re-rendering this document is fast. An 11-year window is downloaded (rather than just 10) to leave enough of a buffer for the backtest’s rolling lookback window and for the FTSE 100 index (^FTSE), which is included as a market benchmark alongside the S&P 500 (^GSPC).

Show code
to_date   <- as.Date("2026-07-02")
from_date <- to_date - round(365.25 * 11)

uk_data <- stockDataDownload(stock_symbols = uk_tickers, index_symbol = "^FTSE",
                              from = as.character(from_date), to = as.character(to_date),
                              rm_stocks_with_na = FALSE, local_file_path = data_dir)

# S&P 500: portfolioBacktest only supports one index_symbol, so this second
# benchmark is fetched directly and cached alongside the other stock data.
sp500_cache <- file.path(data_dir, "sp500.rds")
if (file.exists(sp500_cache)) {
  sp500 <- readRDS(sp500_cache)
} else {
  sp500 <- Ad(getSymbols("^GSPC", from = as.character(from_date), to = as.character(to_date), auto.assign = FALSE))
  colnames(sp500) <- "SP500"
  saveRDS(sp500, sp500_cache)
}

Cleaning

Two data-quality issues surfaced during validation and are handled explicitly rather than silently:

  1. Isolated single-day gaps. A handful of tickers (and the FTSE 100 index) have an isolated missing bar on an otherwise ordinary trading day — a Yahoo Finance data artifact, not a real trading halt. These are patched with last-observation-carried-forward (na.locf), which only affects those single days.
  2. Genuinely short histories. Two constituents were not independently listed for the full 11-year window:
    • MNG.L (M&G plc) demerged from Prudential in October 2019 — prices before that date are correctly flagged NA.
    • AIBG.L (AIB Group) listed on the LSE in June 2017 — Yahoo backfills a flat placeholder price before that date instead of NA, so it must be detected separately (a constant-price run from the start of the series).

Because both stocks were listed within the last 10 years, they cannot contribute a genuine 10-year return series. The 5-year and shorter (1Y/3Y) analysis uses the full 15-stock portfolio (data trimmed to start at MNG.L’s listing, which is the more recent of the two constraints). The 10-year analysis uses a 13-stock portfolio excluding MNG.L and AIBG.L, with their weight redistributed proportionally across the remaining stocks. The Top Holdings Correlation table and per-stock return figures use the same MNG.L-listing cutoff for a consistent, fully-overlapping comparison window.

Show code
patch_na <- function(x) na.locf(x, na.rm = FALSE)
for (nm in c("open", "high", "low", "close", "volume", "adjusted", "index")) {
  uk_data[[nm]] <- patch_na(uk_data[[nm]])
}

# MNG.L: genuine leading NA before its Oct-2019 demerger from Prudential.
mng_start <- index(uk_data$adjusted)[which(!is.na(uk_data$adjusted[, "MNG.L"]))[1]]

# AIBG.L: flat placeholder price before its Jun-2017 LSE listing (no NA flag,
# so detected as a constant run from the start of the series).
detect_real_start <- function(x) {
  v <- as.numeric(x); n <- length(v); first <- v[1]; run <- 1
  while (run < n && !is.na(v[run + 1]) && v[run + 1] == first) run <- run + 1
  index(x)[run + 1]
}
aibg_start <- detect_real_start(uk_data$adjusted[, "AIBG.L"])

cat("MNG.L first valid trading date:", as.character(mng_start), "\n")
MNG.L first valid trading date: 2019-10-21 
Show code
cat("AIBG.L first valid trading date:", as.character(aibg_start), "\n")
AIBG.L first valid trading date: 2017-06-27 
Show code
# Full 15-stock dataset, trimmed to the later of the two listing constraints
full_data <- lapply(uk_data, function(x) x[paste0(mng_start, "/")])
dataset_full <- list("UK_Dividend_Portfolio" = full_data)

# 10-year dataset: exclude MNG.L and AIBG.L, renormalise remaining weights
tickers_10y <- setdiff(uk_tickers, c("MNG.L", "AIBG.L"))
w_10y <- w_target[tickers_10y]
w_10y <- w_10y / sum(w_10y)

data_10y <- uk_data
for (nm in c("open", "high", "low", "close", "volume", "adjusted")) {
  data_10y[[nm]] <- data_10y[[nm]][, tickers_10y]
}
start_10y <- to_date - round(365.25 * 10) - 90   # extra buffer for lookback burn-in
data_10y <- lapply(data_10y, function(x) x[paste0(start_10y, "/")])
dataset_10y <- list("UK_Dividend_Portfolio_10y" = data_10y)

Backtest Configuration

The portfolio is rebalanced back to the target weights every quarter (63 trading days). Since the target weights are fixed (they don’t depend on the price history), a short lookback window is sufficient — it exists only because portfolioBacktest() requires one.

Show code
my_target_portfolio <- function(dataset, w_current) {
  w_target[colnames(dataset$adjusted)]
}
my_target_portfolio_10y <- function(dataset, w_current) {
  w_10y[colnames(dataset$adjusted)]
}

bt_full <- portfolioBacktest(list("Target" = my_target_portfolio), dataset_full,
                              benchmarks = c("index"), price_name = "adjusted",
                              lookback = 22, optimize_every = 63, rebalance_every = 63,
                              bars_per_year = 252)

bt_10y <- portfolioBacktest(list("Target" = my_target_portfolio_10y), dataset_10y,
                             benchmarks = c("index"), price_name = "adjusted",
                             lookback = 22, optimize_every = 63, rebalance_every = 63,
                             bars_per_year = 252)

Performance & Risk Calculations

Growth series, trailing/annualised performance, calendar-year returns, risk/return positioning, up/down capture, and holdings correlation are all computed here; the Trailing Performance section below only formats and displays these results.

Show code
series_labels <- c("Target" = "UK Dividend", "index" = "FTSE 100")
series_colors <- c("UK Dividend" = "#d7191c", "FTSE 100" = "#1a9641", "S&P 500" = "#2c7fb8")
portfolio_order <- c("UK Dividend", "FTSE 100", "S&P 500")

get_wealth <- function(bt, ds_name, port_name) {
  w <- bt[[port_name]][[ds_name]]$wealth
  colnames(w) <- port_name
  w
}

# Build one merged, gap-filled series per backtest (covering its FULL history)
# before ever slicing into 1/5/10-year windows. Filling gaps first avoids the
# case where a window happens to start on a market holiday for one series
# (e.g. US Independence Day) and rebasing-to-100 divides by a stray NA.
build_full_series <- function(bt, ds_name, sp500) {
  series_list <- lapply(c("Target", "index"), get_wealth, bt = bt, ds_name = ds_name)
  w_all <- do.call(merge, series_list)
  w_all <- merge(w_all, sp500)
  w_all <- na.locf(w_all, na.rm = FALSE)
  na.locf(w_all, na.rm = FALSE, fromLast = TRUE)
}

build_chart_data <- function(full_series, years, end_date) {
  start_date <- end_date - as.difftime(365.25 * years, units = "days")
  w_all <- full_series[paste0(start_date, "/", end_date)]
  w_all <- w_all / rep(as.numeric(w_all[1, ]), each = nrow(w_all)) * 100
  df <- fortify.zoo(w_all)
  names(df)[1] <- "date"
  df <- pivot_longer(df, -date, names_to = "series", values_to = "value")
  df$series <- ifelse(df$series == "SP500", "S&P 500", series_labels[df$series])
  df
}

full_series_full <- build_full_series(bt_full, "UK_Dividend_Portfolio", sp500)
full_series_10y  <- build_full_series(bt_10y, "UK_Dividend_Portfolio_10y", sp500)

chart1  <- build_chart_data(full_series_full, 1, to_date)
chart5  <- build_chart_data(full_series_full, 5, to_date)
chart10 <- build_chart_data(full_series_10y, 10, to_date)

plot_growth <- function(df, title) {
  end_labels <- df |>
    group_by(series) |>
    filter(date == max(date)) |>
    ungroup() |>
    mutate(label = paste0("£", comma(round(value, 0))))
  nudge <- as.numeric(diff(range(df$date))) * 0.015

  ggplot(df, aes(date, value, color = series)) +
    geom_line(linewidth = 0.9) +
    geom_text(data = end_labels, aes(label = label), hjust = 0, nudge_x = nudge,
              size = 3.3, fontface = "bold", show.legend = FALSE) +
    scale_color_manual(values = series_colors) +
    scale_x_date(expand = expansion(mult = c(0.02, 0.14))) +
    labs(title = title, x = NULL, y = "Growth of £100", color = NULL) +
    theme_minimal(base_size = 12) +
    theme(legend.position = "top", plot.margin = margin(5.5, 46, 5.5, 5.5)) +
    coord_cartesian(clip = "off")
}
Show code
extract_total_return <- function(df, horizon_label) {
  df |>
    group_by(series) |>
    filter(date == max(date)) |>
    ungroup() |>
    transmute(Portfolio = series, Horizon = horizon_label,
              `Total Return` = value / 100 - 1)
}

total_perf <- bind_rows(
  extract_total_return(chart1, "1Y"),
  extract_total_return(chart5, "5Y"),
  extract_total_return(chart10, "10Y")
) |>
  mutate(Portfolio = factor(Portfolio, levels = portfolio_order)) |>
  arrange(factor(Horizon, levels = c("1Y", "5Y", "10Y")), Portfolio)
Show code
trailing_window <- function(x, years, end_date) {
  start_date <- end_date - as.difftime(365.25 * years, units = "days")
  x[paste0(start_date, "/", end_date)]
}

perf_summary <- function(wealth, returns, years, end_date, bars_per_year = 252) {
  w <- trailing_window(wealth, years, end_date)
  r <- trailing_window(returns, years, end_date)
  if (nrow(w) < 2) return(NULL)
  n_years <- as.numeric(difftime(index(w)[nrow(w)], index(w)[1], units = "days")) / 365.25
  cagr    <- (as.numeric(w[nrow(w)]) / as.numeric(w[1]))^(1 / n_years) - 1
  ann_vol <- as.numeric(StdDev.annualized(r, scale = bars_per_year))
  sharpe  <- as.numeric(SharpeRatio.annualized(r, Rf = 0, scale = bars_per_year))
  maxdd   <- as.numeric(maxDrawdown(r))
  data.frame(Horizon = paste0(years, "Y"),
             `Annualised Return` = cagr, `Annualised Volatility` = ann_vol,
             `Sharpe Ratio` = sharpe, `Max Drawdown` = maxdd,
             `Years Covered` = round(n_years, 2), check.names = FALSE)
}

# Target-portfolio-only figures, reused later in the Combined Summary table
perf_rows <- lapply(c(1, 3, 5), function(yrs) {
  perf_summary(bt_full$Target$UK_Dividend_Portfolio$wealth,
               bt_full$Target$UK_Dividend_Portfolio$return, yrs, to_date)
})
perf_rows[[4]] <- perf_summary(bt_10y$Target$UK_Dividend_Portfolio_10y$wealth,
                                bt_10y$Target$UK_Dividend_Portfolio_10y$return, 10, to_date)
perf_table <- bind_rows(perf_rows)
Show code
sp500_returns <- CalculateReturns(sp500)[-1]

build_perf_row <- function(label, wealth, returns, years, end_date) {
  res <- perf_summary(wealth, returns, years, end_date)
  if (is.null(res)) return(NULL)
  res$Portfolio <- label
  res
}

bench_rows_for <- function(bt, ds_name, years) {
  bind_rows(
    build_perf_row("UK Dividend", bt$Target[[ds_name]]$wealth, bt$Target[[ds_name]]$return, years, to_date),
    build_perf_row("FTSE 100", bt$index[[ds_name]]$wealth, bt$index[[ds_name]]$return, years, to_date),
    build_perf_row("S&P 500", sp500, sp500_returns, years, to_date)
  )
}

bench_all <- bind_rows(
  lapply(c(1, 3, 5), bench_rows_for, bt = bt_full, ds_name = "UK_Dividend_Portfolio"),
  list(bench_rows_for(bt_10y, "UK_Dividend_Portfolio_10y", 10))
) |>
  select(Portfolio, Horizon, `Annualised Return`, `Annualised Volatility`, `Sharpe Ratio`, `Max Drawdown`) |>
  mutate(Portfolio = factor(Portfolio, levels = portfolio_order)) |>
  arrange(factor(Horizon, levels = c("1Y", "3Y", "5Y", "10Y")), Portfolio)

Up/down capture is computed on daily returns (not the monthly returns vendors typically use), over the trailing 5-year window, against the FTSE 100:

Show code
calc_capture <- function(port_ret, bench_ret, years, end_date) {
  ra <- trailing_window(port_ret, years, end_date)
  rb <- trailing_window(bench_ret, years, end_date)
  common <- merge(ra, rb, join = "inner")
  ra <- as.numeric(common[, 1]); rb <- as.numeric(common[, 2])
  up   <- which(rb > 0)
  down <- which(rb < 0)
  up_capture   <- (prod(1 + ra[up]) - 1)   / (prod(1 + rb[up]) - 1)
  down_capture <- (prod(1 + ra[down]) - 1) / (prod(1 + rb[down]) - 1)
  data.frame(`Up Capture` = up_capture, `Down Capture` = down_capture, check.names = FALSE)
}

capture_ratios <- calc_capture(bt_full$Target$UK_Dividend_Portfolio$return,
                                bt_full$index$UK_Dividend_Portfolio$return, 5, to_date)

Calendar-year returns are read off the last trading day of each full calendar year in the full 15-stock wealth series; the current (partial) year is dropped, as is the first year if the series doesn’t start on 1 January:

Show code
year_end <- apply.yearly(full_series_full, function(x) x[nrow(x), ])
ye_df <- fortify.zoo(year_end)
names(ye_df)[1] <- "date"
ye_df$year <- format(ye_df$date, "%Y")

n <- nrow(ye_df)
calendar_wide <- ye_df
calendar_wide[2:n, c("Target", "index", "SP500")] <-
  calendar_wide[2:n, c("Target", "index", "SP500")] / calendar_wide[1:(n - 1), c("Target", "index", "SP500")] - 1
calendar_wide[1, c("Target", "index", "SP500")] <- NA

calendar_long <- calendar_wide |>
  filter(!is.na(Target)) |>
  filter(year != format(to_date, "%Y")) |>
  select(year, Target, index, SP500) |>
  pivot_longer(cols = c(Target, index, SP500), names_to = "series", values_to = "return")
calendar_long$series <- ifelse(calendar_long$series == "SP500", "S&P 500", series_labels[calendar_long$series])
calendar_long$series <- factor(calendar_long$series, levels = portfolio_order)
calendar_long$label_vjust <- ifelse(calendar_long$return >= 0, -0.3, 1.1)

Top holdings correlation uses daily returns over the same full-15-stock window (since MNG.L’s listing), for all 15 stocks plus the FTSE 100 and S&P 500:

Show code
returns_all_stocks <- CalculateReturns(uk_data$adjusted[paste0(mng_start, "/")])[-1, ]

returns_ftse <- CalculateReturns(uk_data$index[paste0(mng_start, "/")])[-1, ]
colnames(returns_ftse) <- "FTSE 100"

returns_sp500_corr <- CalculateReturns(sp500[paste0(mng_start, "/")])[-1, ]
colnames(returns_sp500_corr) <- "S&P 500"

corr_data <- merge(returns_all_stocks, returns_ftse, returns_sp500_corr, join = "inner")
corr_matrix <- cor(as.matrix(corr_data), use = "pairwise.complete.obs")

Stress-test windows are hand-picked, well-known market episodes, constrained to fall after MNG.L’s October 2019 listing so the UK Dividend portfolio’s own return is computable over the exact same window as the benchmarks (see Methodology Notes):

Show code
stress_periods <- data.frame(
  label = c("COVID-19 Crash", "COVID-19 Recovery", "2022 Rate-Hike Bear Market", "UK Gilt Crisis (Mini-Budget)",
            "2025 Liberation Day Tariff Shock"),
  start = c("2020-02-19", "2020-03-24", "2022-01-03", "2022-09-22", "2025-04-02"),
  end   = c("2020-03-23", "2020-12-31", "2022-10-12", "2022-10-14", "2025-04-08"),
  stringsAsFactors = FALSE
)

calc_period_return <- function(full_series, start, end) {
  w <- full_series[paste0(start, "/", end)]
  if (nrow(w) < 2) return(setNames(rep(NA_real_, ncol(full_series)), colnames(full_series)))
  setNames(as.numeric(w[nrow(w), ]) / as.numeric(w[1, ]) - 1, colnames(full_series))
}

stress_long <- map_dfr(seq_len(nrow(stress_periods)), function(i) {
  ret <- calc_period_return(full_series_full, stress_periods$start[i], stress_periods$end[i])
  data.frame(Period = stress_periods$label[i], series = names(ret), Return = as.numeric(ret))
})
stress_long$series <- ifelse(stress_long$series == "SP500", "S&P 500", series_labels[stress_long$series])
stress_long$Period <- factor(stress_long$Period, levels = stress_periods$label)
stress_long$series <- factor(stress_long$series, levels = portfolio_order)

Dividend & Per-Stock Calculations

Dividend history and per-stock trailing total returns are calculated here, per ticker, for reuse in the Dividend Yield and Individual Investments sections.

Show code
div_from <- as.character(to_date - round(365.25 * 11))
div_list <- lapply(uk_tickers, function(tk) {
  tryCatch(getDividends(tk, from = div_from, to = as.character(to_date), auto.assign = FALSE),
            error = function(e) NULL)
})
names(div_list) <- uk_tickers

close_px <- uk_data$close

calc_yield <- function(tk, years, end_date) {
  start_date <- end_date - round(365.25 * years)
  divs <- div_list[[tk]]
  if (is.null(divs)) return(NA)
  total_div <- sum(divs[paste0(start_date, "/", end_date)])
  px <- close_px[paste0(start_date, "/", end_date), tk]
  px <- px[!is.na(px)]
  if (length(px) == 0) return(NA)
  (total_div / years) / mean(px)
}

yield_rows <- lapply(c(1, 3, 5, 10), function(yrs) {
  w_use <- if (yrs == 10) w_10y else w_target
  yields <- sapply(names(w_use), calc_yield, years = yrs, end_date = to_date)
  port_yield <- sum(yields * w_use[names(yields)], na.rm = TRUE) / sum(w_use[!is.na(yields)])
  data.frame(Horizon = paste0(yrs, "Y"), `Avg Annual Dividend Yield` = port_yield, check.names = FALSE)
})
yield_table <- bind_rows(yield_rows)
Show code
per_stock_yield <- map_dfr(c(1, 3, 5, 10), function(yrs) {
  data.frame(Ticker = uk_tickers,
             Horizon = paste0(yrs, "Y"),
             Yield = sapply(uk_tickers, calc_yield, years = yrs, end_date = to_date))
}) |>
  pivot_wider(names_from = Horizon, values_from = Yield)

Per-stock trailing returns use the same CAGR methodology as the portfolio-level figures, but require a stock’s price history to genuinely cover the full nominal window (within a 5-day tolerance) before reporting a horizon — otherwise a late listing (MNG.L, AIBG.L) would silently produce a shorter-period return mislabeled as, say, “10Y”:

Show code
window_return <- function(px, start_date, end_date, require_full_coverage = FALSE) {
  w_full <- px[paste0(start_date, "/", end_date)]
  w <- w_full[!is.na(w_full)]
  if (length(w) < 2) return(list(ret = NA_real_, years = NA_real_))
  if (require_full_coverage && index(w)[1] > as.Date(start_date) + 5) {
    return(list(ret = NA_real_, years = NA_real_))
  }
  ret <- as.numeric(w[length(w)]) / as.numeric(w[1]) - 1
  yrs <- as.numeric(difftime(index(w)[length(w)], index(w)[1], units = "days")) / 365.25
  list(ret = ret, years = yrs)
}

calc_stock_cagr <- function(px, years, end_date) {
  start_date <- end_date - as.difftime(365.25 * years, units = "days")
  res <- window_return(px, start_date, end_date, require_full_coverage = TRUE)
  if (is.na(res$ret) || is.na(res$years) || res$years < 0.1) return(NA_real_)
  (1 + res$ret)^(1 / res$years) - 1
}

calc_stock_ytd <- function(px, end_date) {
  start_date <- as.Date(paste0(format(end_date, "%Y"), "-01-01"))
  res <- window_return(px, start_date, end_date, require_full_coverage = FALSE)
  res$ret
}

per_stock_returns <- map_dfr(uk_tickers, function(tk) {
  px <- uk_data$adjusted[, tk]
  data.frame(
    Ticker = tk,
    YTD = calc_stock_ytd(px, to_date),
    `1Y` = calc_stock_cagr(px, 1, to_date),
    `3Y` = calc_stock_cagr(px, 3, to_date),
    `5Y` = calc_stock_cagr(px, 5, to_date),
    `10Y` = calc_stock_cagr(px, 10, to_date),
    check.names = FALSE
  )
}) |>
  left_join(company_info |> select(Ticker, Name, Sector), by = "Ticker") |>
  select(Ticker, Name, Sector, YTD, `1Y`, `3Y`, `5Y`, `10Y`)

The equity style box (displayed in Portfolio Composition) combines two dimensions: style (Value/Core/Growth) is a computed proxy from trailing 5-year average dividend yield terciles — the five highest-yielding holdings are classified Value, the five lowest Growth, the middle five Core. This is a transparent, reproducible proxy, not the official Morningstar/FTSE Russell style classification (which also weighs valuation multiples and earnings growth). Size (Large/Mid/Small) is a manual classification, since this pipeline doesn’t fetch live market-capitalisation data — see Methodology Notes.

Show code
yield_rank <- per_stock_yield |>
  select(Ticker, Yield5Y = `5Y`) |>
  arrange(desc(Yield5Y)) |>
  mutate(style_rank = row_number(),
         Style = case_when(
           style_rank <= 5  ~ "Value",
           style_rank <= 10 ~ "Core",
           TRUE              ~ "Growth"
         ))

# Manual size classification — all 15 holdings are large-cap UK (or, for
# AIBG.L, equivalent large-cap) blue-chip dividend payers based on general
# knowledge as of this report's writing; not a live data feed (see Methodology Notes).
size_class <- setNames(rep("Large", length(uk_tickers)), uk_tickers)

style_data <- company_info |>
  left_join(yield_rank |> select(Ticker, Style), by = "Ticker") |>
  mutate(Size = size_class[Ticker])

style_weights <- style_data |>
  group_by(Size, Style) |>
  summarise(Weight = sum(Weight), .groups = "drop") |>
  mutate(Size = factor(Size, levels = c("Large", "Mid", "Small")),
         Style = factor(Style, levels = c("Value", "Core", "Growth"))) |>
  complete(Size, Style, fill = list(Weight = 0)) |>
  pivot_wider(names_from = Style, values_from = Weight)

style_totals <- data.frame(
  Size = "Total",
  Value = sum(style_weights$Value),
  Core = sum(style_weights$Core),
  Growth = sum(style_weights$Growth)
)

style_grid <- bind_rows(style_weights |> mutate(Size = as.character(Size)), style_totals)

Key Observations

Show code
sw <- sector_weights |> arrange(desc(Weight))
top_sector <- sw$Sector[1]
top_sector_weight <- sw$Weight[1]
n_sectors <- nrow(sw)

vol_5y      <- bench_all |> filter(Horizon == "5Y")
port_vol_5y <- vol_5y$`Annualised Volatility`[vol_5y$Portfolio == "UK Dividend"]
ftse_vol_5y <- vol_5y$`Annualised Volatility`[vol_5y$Portfolio == "FTSE 100"]
vol_word    <- ifelse(port_vol_5y > ftse_vol_5y, "higher", "lower")

stock_corr <- corr_matrix[uk_tickers, uk_tickers]
avg_corr   <- mean(stock_corr[lower.tri(stock_corr)], na.rm = TRUE)
corr_word  <- ifelse(avg_corr > 0.6, "limited", "some")

port_yield_5y <- yield_table$`Avg Annual Dividend Yield`[yield_table$Horizon == "5Y"]

up_pct   <- capture_ratios$`Up Capture`
down_pct <- capture_ratios$`Down Capture`
capture_word <- ifelse(up_pct - down_pct > 0, "more upside than downside", "more downside than upside")

cat("*The following are descriptive observations drawn directly from the calculations in this report; they are not investment recommendations.*\n\n")

The following are descriptive observations drawn directly from the calculations in this report; they are not investment recommendations.

Show code
cat(sprintf("1. **Sector concentration.** %s is the largest sector exposure at %s of target weight, across %d sectors represented in the 15-stock portfolio.\n\n",
            top_sector, percent(top_sector_weight, accuracy = 0.1), n_sectors))
  1. Sector concentration. Financials is the largest sector exposure at 50.0% of target weight, across 5 sectors represented in the 15-stock portfolio.
Show code
cat(sprintf("2. **Volatility versus benchmark.** Over the trailing 5 years, the portfolio's annualised volatility (%s) is %s than the FTSE 100's (%s).\n\n",
            percent(port_vol_5y, accuracy = 0.1), vol_word, percent(ftse_vol_5y, accuracy = 0.1)))
  1. Volatility versus benchmark. Over the trailing 5 years, the portfolio’s annualised volatility (17.2%) is higher than the FTSE 100’s (12.6%).
Show code
cat(sprintf("3. **Holding correlation.** The average pairwise correlation among the 15 holdings over the trailing 5 years is %.2f, reflecting the portfolio's concentration in UK large-caps and suggesting %s incremental diversification benefit from adding further UK equity holdings.\n\n",
            avg_corr, corr_word))
  1. Holding correlation. The average pairwise correlation among the 15 holdings over the trailing 5 years is 0.29, reflecting the portfolio’s concentration in UK large-caps and suggesting some incremental diversification benefit from adding further UK equity holdings.
Show code
cat(sprintf("4. **Dividend income.** The portfolio's trailing 5-year average annual dividend yield is %s.\n\n",
            percent(port_yield_5y, accuracy = 0.1)))
  1. Dividend income. The portfolio’s trailing 5-year average annual dividend yield is 4.4%.
Show code
cat(sprintf("5. **Market participation.** Over the trailing 5 years, the portfolio captured %s of FTSE 100 upside and %s of FTSE 100 downside on a daily basis — %s.\n\n",
            percent(up_pct, accuracy = 0.1), percent(down_pct, accuracy = 0.1), capture_word))
  1. Market participation. Over the trailing 5 years, the portfolio captured 293.0% of FTSE 100 upside and 101.1% of FTSE 100 downside on a daily basis — more upside than downside.

Portfolio Summary

Here’s how the UK Dividend portfolio compares to its benchmarks at a glance, using trailing 5-year figures (the longest horizon computable for the full 15-stock portfolio against both benchmarks on a like-for-like basis).

Show code
sum_5y <- bench_all |> filter(Horizon == "5Y")
get_val <- function(col, port) sum_5y[[col]][sum_5y$Portfolio == port]

summary_table <- data.frame(
  Metric = c("Annualised Return", "Annualised Volatility", "Sharpe Ratio", "Max Drawdown", "Avg. Annual Dividend Yield"),
  `UK Dividend` = c(get_val("Annualised Return", "UK Dividend"),
                    get_val("Annualised Volatility", "UK Dividend"),
                    get_val("Sharpe Ratio", "UK Dividend"),
                    get_val("Max Drawdown", "UK Dividend"),
                    yield_table$`Avg Annual Dividend Yield`[yield_table$Horizon == "5Y"]),
  `FTSE 100` = c(get_val("Annualised Return", "FTSE 100"),
                 get_val("Annualised Volatility", "FTSE 100"),
                 get_val("Sharpe Ratio", "FTSE 100"),
                 get_val("Max Drawdown", "FTSE 100"),
                 NA),
  `S&P 500` = c(get_val("Annualised Return", "S&P 500"),
                get_val("Annualised Volatility", "S&P 500"),
                get_val("Sharpe Ratio", "S&P 500"),
                get_val("Max Drawdown", "S&P 500"),
                NA),
  check.names = FALSE
)

summary_table |>
  gt() |>
  fmt_percent(columns = c(`UK Dividend`, `FTSE 100`, `S&P 500`),
              rows = Metric != "Sharpe Ratio", decimals = 2) |>
  fmt_number(columns = c(`UK Dividend`, `FTSE 100`, `S&P 500`),
             rows = Metric == "Sharpe Ratio", decimals = 2) |>
  sub_missing(columns = everything(), missing_text = "—") |>
  tab_header(title = "UK Dividend at a Glance",
             subtitle = "5-year trailing figures vs benchmark")
UK Dividend at a Glance
5-year trailing figures vs benchmark
Metric UK Dividend FTSE 100 S&P 500
Annualised Return 24.49% 8.03% 11.46%
Annualised Volatility 17.24% 12.64% 17.01%
Sharpe Ratio 1.35 0.67 0.73
Max Drawdown 16.37% 13.43% 25.43%
Avg. Annual Dividend Yield 4.40%

Dividend yield is shown for the portfolio only — FTSE 100 and S&P 500 index-level dividend yield isn’t fetched by this pipeline (see Methodology Notes).

Portfolio Composition

Investments

Show code
company_info |>
  select(Ticker, Name, Sector, Weight) |>
  arrange(desc(Weight)) |>
  gt() |>
  cols_label(Ticker = "Symbol", Weight = "Target Weight") |>
  fmt_percent(columns = Weight, decimals = 2) |>
  cols_align(align = "left", columns = c(Ticker, Name, Sector)) |>
  cols_align(align = "right", columns = Weight) |>
  tab_options(
    table.border.top.style = "solid", table.border.top.width = px(2), table.border.top.color = "#333333",
    table.border.bottom.style = "solid", table.border.bottom.width = px(2), table.border.bottom.color = "#333333",
    column_labels.border.bottom.style = "solid", column_labels.border.bottom.width = px(2),
    column_labels.border.bottom.color = "#333333",
    row.striping.include_table_body = FALSE,
    table_body.hlines.style = "solid", table_body.hlines.width = px(1), table_body.hlines.color = "#e0e0e0",
    data_row.padding = px(6)
  ) |>
  tab_header(title = "Investments", subtitle = "15 holdings by target allocation")
Investments
15 holdings by target allocation
Symbol Name Sector Target Weight
RR.L Rolls-Royce Holdings Industrials 10.00%
LLOY.L Lloyds Banking Group Financials 10.00%
RIO.L Rio Tinto Materials 10.00%
STAN.L Standard Chartered Financials 10.00%
HSBA.L HSBC Holdings Financials 10.00%
AZN.L AstraZeneca Health Care 5.00%
DPLM.L Diploma Industrials 5.00%
NG.L National Grid Utilities 5.00%
AIBG.L AIB Group Financials 5.00%
ANTO.L Antofagasta Materials 5.00%
ADM.L Admiral Group Financials 5.00%
HLMA.L Halma Industrials 5.00%
MNG.L M&G Financials 5.00%
BA.L BAE Systems Industrials 5.00%
LGEN.L Legal & General Financials 5.00%

Overall Composition

The outer ring shows the target weight of each individual holding; the inner ring aggregates the same holdings by sector, with matching angular boundaries so each company sits directly beneath its own sector (e.g. HSBA.L sits within the Financials wedge, not Health Care).

Show code
sector_order <- c("Financials", "Industrials", "Materials", "Health Care", "Utilities")
sector_colors <- c("Financials" = "#2c7fb8", "Industrials" = "#d95f02",
                    "Materials" = "#7570b3", "Health Care" = "#1b9e77", "Utilities" = "#e7298a")

shade_color <- function(hex, frac) {
  rgb_col <- col2rgb(hex) / 255
  mixed <- rgb_col * (1 - frac) + c(1, 1, 1) * frac
  rgb(mixed[1], mixed[2], mixed[3])
}

donut_companies <- company_info |>
  mutate(Sector = factor(Sector, levels = sector_order)) |>
  arrange(Sector, desc(Weight), Ticker) |>
  group_by(Sector) |>
  mutate(shade_idx = row_number(), n_in_sector = n()) |>
  ungroup() |>
  mutate(xmax = cumsum(Weight), xmin = lag(xmax, default = 0), xmid = (xmin + xmax) / 2,
         base_color = sector_colors[as.character(Sector)]) |>
  rowwise() |>
  mutate(fill_color = shade_color(base_color, 0.15 + 0.55 * (shade_idx - 1) /
                                     pmax(1, n_in_sector - 1) * (n_in_sector > 1))) |>
  ungroup()

donut_sectors <- donut_companies |>
  group_by(Sector) |>
  summarise(Weight = sum(Weight), xmin = min(xmin), xmax = max(xmax), .groups = "drop") |>
  mutate(xmid = (xmin + xmax) / 2, fill_color = sector_colors[as.character(Sector)])

ggplot() +
  geom_rect(data = donut_companies, aes(xmin = xmin, xmax = xmax, ymin = 2, ymax = 3.4, fill = fill_color),
            color = "white", linewidth = 0.4) +
  geom_rect(data = donut_sectors, aes(xmin = xmin, xmax = xmax, ymin = 0.6, ymax = 1.9, fill = fill_color),
            color = "white", linewidth = 0.4) +
  geom_text(data = donut_companies, aes(x = xmid, y = 3.75, label = Ticker), size = 2.8) +
  geom_text(data = donut_sectors, aes(x = xmid, y = 1.25, label = Sector,
                                      size = pmin(4.5, 2.2 + 8 * Weight)),
            color = "white", fontface = "bold", show.legend = FALSE) +
  scale_size_identity() +
  scale_fill_identity() +
  coord_polar(theta = "x") +
  xlim(0, 1) + ylim(0, 4.2) +
  theme_void() +
  theme(legend.position = "none",
        plot.title = element_text(hjust = 0.5)) +
  labs(title = "Asset Allocation (outer) and Sector Breakdown (inner)")

Equity Style Box

Style weights — target weight (%) in each of the nine Style × Size cells, shaded by magnitude within each column (darker = larger weight); the Total row is unshaded.

Show code
style_max <- max(style_grid[style_grid$Size != "Total", c("Value", "Core", "Growth")])

style_grid |>
  gt() |>
  fmt_percent(columns = c(Value, Core, Growth), decimals = 2) |>
  data_color(
    columns = c(Value, Core, Growth),
    rows = Size != "Total",
    method = "numeric",
    palette = c("#f3e5f5", "#6a1b9a"),
    domain = c(0, style_max)
  ) |>
  cols_label(Size = "") |>
  tab_style(style = cell_borders(sides = "top", color = "#333333", weight = px(2)),
            locations = cells_body(rows = Size == "Total")) |>
  tab_header(title = "Equity Style Weights",
             subtitle = "Value/Core/Growth by trailing 5-year dividend yield tercile; Size is a manual large-cap classification")
Equity Style Weights
Value/Core/Growth by trailing 5-year dividend yield tercile; Size is a manual large-cap classification
Value Core Growth
Large 35.00% 35.00% 30.00%
Mid 0.00% 0.00% 0.00%
Small 0.00% 0.00% 0.00%
Total 35.00% 35.00% 30.00%

Style map — the UK Dividend portfolio’s weighted centroid (computed from the 15 holdings’ Style/Size positions above), with an illustrative “ownership zone” showing the weighted spread of those same holdings. FTSE 100 and S&P 500 are plotted too, but — unlike the UK Dividend point — their positions are not computed from this pipeline’s data; this pipeline only holds their index price level, not constituent-level style/cap data. They’re placed at a rough, commonly-cited characterization instead: FTSE 100 is widely described as value/income-tilted (heavy in financials, energy, mining, pharma); S&P 500 index trackers are conventionally Morningstar-categorised as Large Blend/Core. See Methodology Notes.

Show code
style_axis <- c(Value = 1, Core = 2, Growth = 3)
size_axis  <- c(Small = 1, Mid = 2, Large = 3)

style_positions <- style_data |>
  mutate(style_num = style_axis[Style], size_num = size_axis[Size])

w  <- style_positions$Weight
wx <- style_positions$style_num
wy <- style_positions$size_num

centroid_x <- sum(wx * w) / sum(w)
centroid_y <- sum(wy * w) / sum(w)
# weighted spread of the 15 holdings around their own centroid. Note this
# will span most of the Value-Growth width more or less by construction,
# since Style terciles guarantee roughly equal-sized Value/Core/Growth
# groups — a wide zone here reflects the tercile methodology as much as it
# reflects genuine style diversification.
spread_x <- sqrt(sum(w * (wx - centroid_x)^2) / sum(w))
spread_y <- sqrt(sum(w * (wy - centroid_y)^2) / sum(w))  # 0: all 15 are Large

make_ellipse <- function(cx, cy, rx, ry, n = 100) {
  theta <- seq(0, 2 * pi, length.out = n)
  data.frame(x = cx + rx * cos(theta), y = cy + ry * sin(theta))
}
ownership_zone <- make_ellipse(centroid_x, centroid_y,
                                rx = max(spread_x * 1.5, 0.35),
                                ry = max(spread_y * 1.5, 0.18))

benchmark_points <- data.frame(
  series = c("FTSE 100", "S&P 500"),
  style_num = c(1, 2),
  size_num = c(3, 3)
)

style_map_points <- bind_rows(
  data.frame(series = "UK Dividend", style_num = centroid_x, size_num = centroid_y),
  benchmark_points
) |>
  mutate(series = factor(series, levels = portfolio_order))

ggplot() +
  geom_vline(xintercept = c(0.5, 1.5, 2.5, 3.5), color = "grey85") +
  geom_hline(yintercept = c(0.5, 1.5, 2.5, 3.5), color = "grey85") +
  geom_polygon(data = ownership_zone, aes(x = x, y = y),
               fill = series_colors[["UK Dividend"]], alpha = 0.15,
               color = series_colors[["UK Dividend"]], linewidth = 0.8) +
  geom_point(data = style_map_points, aes(x = style_num, y = size_num, color = series),
             size = 7, shape = 21, stroke = 1.6, fill = "white") +
  geom_point(data = style_map_points, aes(x = style_num, y = size_num, color = series),
             size = 2, shape = 19, show.legend = FALSE) +
  scale_color_manual(values = series_colors) +
  scale_x_continuous(breaks = 1:3, labels = c("Value", "Core", "Growth"), limits = c(0.4, 3.6)) +
  scale_y_continuous(breaks = 1:3, labels = c("Small", "Mid", "Large"), limits = c(0.4, 3.6)) +
  labs(title = "Equity Style Map", x = NULL, y = NULL, color = NULL) +
  theme_minimal(base_size = 12) +
  theme(panel.grid = element_blank(), legend.position = "right")

Trailing Performance

All charts and tables in this section use adjusted (dividend- and split-adjusted) prices for the portfolio and FTSE 100, compared against the S&P 500 (in local USD terms — FX effects are not applied, see Methodology Notes), so dividends are treated as reinvested throughout. The 1/3/5-year figures come from the full 15-stock portfolio; the 10-year figures come from the 13-stock (ex M&G, ex AIB Group) portfolio described earlier. The S&P 500 figures are computed directly from its own daily return series.

Total Performance

Cumulative (non-annualised) growth of £100 over each trailing horizon, read directly off the end point of each line in the growth charts that follow.

Show code
total_perf |>
  gt(groupname_col = "Horizon") |>
  fmt_percent(columns = `Total Return`, decimals = 2) |>
  tab_header(title = "UK Dividend vs Benchmark",
             subtitle = "Cumulative growth of £100 over each trailing horizon (not annualised)")
UK Dividend vs Benchmark
Cumulative growth of £100 over each trailing horizon (not annualised)
Portfolio Total Return
1Y
UK Dividend 48.29%
FTSE 100 19.41%
S&P 500 20.17%
5Y
UK Dividend 198.82%
FTSE 100 47.10%
S&P 500 71.94%
10Y
UK Dividend 318.70%
FTSE 100 60.65%
S&P 500 255.84%

Calendar Year Returns

Show code
ggplot(calendar_long, aes(x = year, y = return, fill = series)) +
  geom_col(position = position_dodge(width = 0.8), width = 0.7) +
  geom_hline(yintercept = 0, linewidth = 0.4) +
  geom_text(aes(label = percent(return, accuracy = 0.1), vjust = label_vjust),
            position = position_dodge(width = 0.8), size = 2.6, show.legend = FALSE) +
  scale_fill_manual(values = series_colors) +
  scale_y_continuous(labels = percent) +
  labs(title = "Calendar Year Returns: UK Dividend vs Benchmark", x = NULL, y = "Total Return", fill = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top")

1-Year Growth

Show code
plot_growth(chart1, "1-Year Growth of £100 — UK Dividend")

5-Year Growth

Show code
plot_growth(chart5, "5-Year Growth of £100 — UK Dividend")

10-Year Growth

Show code
plot_growth(chart10, "10-Year Growth of £100 — UK Dividend (ex M&G, ex AIB Group)")

Annualised Performance

For each trailing horizon, annualised return is computed as the CAGR of the wealth curve over that exact window; volatility, Sharpe ratio (Rf = 0), and max drawdown are computed from the underlying daily returns using PerformanceAnalytics.

Show code
bench_all |>
  gt(groupname_col = "Horizon") |>
  fmt_percent(columns = c(`Annualised Return`, `Annualised Volatility`, `Max Drawdown`), decimals = 2) |>
  fmt_number(columns = `Sharpe Ratio`, decimals = 2) |>
  tab_header(title = "UK Dividend vs Benchmark",
             subtitle = "Annualised return, volatility, Sharpe ratio, and max drawdown by trailing horizon")
UK Dividend vs Benchmark
Annualised return, volatility, Sharpe ratio, and max drawdown by trailing horizon
Portfolio Annualised Return Annualised Volatility Sharpe Ratio Max Drawdown
1Y
UK Dividend 48.49% 17.20% 2.37 11.27%
FTSE 100 19.49% 11.13% 1.63 9.32%
S&P 500 20.24% 12.56% 1.57 9.10%
3Y
UK Dividend 35.13% 15.59% 2.01 16.22%
FTSE 100 11.68% 11.33% 1.02 13.43%
S&P 500 18.90% 15.01% 1.24 18.90%
5Y
UK Dividend 24.49% 17.24% 1.35 16.37%
FTSE 100 8.03% 12.64% 0.67 13.43%
S&P 500 11.46% 17.01% 0.73 25.43%
10Y
UK Dividend 15.41% 18.47% 0.86 33.76%
FTSE 100 4.86% 14.76% 0.39 36.61%
S&P 500 13.63% 18.05% 0.80 33.92%

Risk/Return

Annualised return plotted against annualised volatility, trailing 5 years — the classic risk/return positioning chart.

Show code
rr_data <- bench_all |>
  filter(Horizon == "5Y") |>
  mutate(Portfolio = factor(Portfolio, levels = portfolio_order))

ggplot(rr_data, aes(x = `Annualised Volatility`, y = `Annualised Return`, color = Portfolio)) +
  geom_point(size = 4) +
  geom_text(aes(label = Portfolio), vjust = -1.3, size = 3.3, show.legend = FALSE) +
  scale_color_manual(values = series_colors) +
  scale_x_continuous(labels = percent, expand = expansion(mult = 0.18)) +
  scale_y_continuous(labels = percent, expand = expansion(mult = c(0.15, 0.25))) +
  labs(title = "Risk/Return: UK Dividend vs Benchmark (5-Year Annualised)",
       x = "Annualised Volatility", y = "Annualised Return", color = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "none")

Up/Down Capture

Share of FTSE 100 daily gains and losses captured by the portfolio, trailing 5 years.

Show code
capture_long <- data.frame(
  Metric = c("Up Capture", "Down Capture"),
  Value = c(capture_ratios$`Up Capture`, capture_ratios$`Down Capture`)
)

ggplot(capture_long, aes(x = Metric, y = Value, fill = Metric)) +
  geom_col(width = 0.5) +
  geom_text(aes(label = percent(Value, accuracy = 0.1)), vjust = -0.4, size = 4, fontface = "bold") +
  scale_fill_manual(values = c("Up Capture" = "#1a9641", "Down Capture" = "#d7191c")) +
  scale_y_continuous(labels = percent, expand = expansion(mult = c(0, 0.15))) +
  labs(title = "Up/Down Capture vs FTSE 100 (5-Year, Daily)", x = NULL, y = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "none")

Top Holdings Correlation

Pairwise correlation of daily returns among all 15 holdings, the FTSE 100, and the S&P 500, since MNG.L’s October 2019 listing (the start of the full-portfolio window). Only the lower triangle is shown, since the matrix is symmetric.

Show code
corr_display <- corr_matrix
corr_display[upper.tri(corr_display)] <- NA
corr_df <- as.data.frame(corr_display)
corr_df <- cbind(Name = rownames(corr_display), corr_df)
rownames(corr_df) <- NULL

corr_df |>
  gt() |>
  fmt_number(columns = -Name, decimals = 2) |>
  sub_missing(columns = everything(), missing_text = "") |>
  tab_header(title = "Top Holdings Correlation",
             subtitle = "Pairwise correlation of daily returns since MNG.L's October 2019 listing")
Top Holdings Correlation
Pairwise correlation of daily returns since MNG.L's October 2019 listing
Name RR.L LLOY.L RIO.L STAN.L HSBA.L AZN.L DPLM.L NG.L AIBG.L ANTO.L ADM.L HLMA.L MNG.L BA.L LGEN.L FTSE.100 S.P.500
RR.L 1.00















LLOY.L 0.50 1.00














RIO.L 0.22 0.36 1.00













STAN.L 0.44 0.62 0.42 1.00












HSBA.L 0.42 0.61 0.42 0.73 1.00











AZN.L 0.05 0.12 0.16 0.14 0.18 1.00










DPLM.L 0.19 0.30 0.22 0.24 0.21 0.20 1.00









NG.L 0.11 0.24 0.17 0.12 0.16 0.33 0.20 1.00








AIBG.L 0.35 0.53 0.27 0.47 0.43 0.04 0.15 0.05 1.00







ANTO.L 0.26 0.39 0.70 0.42 0.41 0.15 0.24 0.13 0.30 1.00






ADM.L 0.09 0.21 0.13 0.16 0.16 0.22 0.23 0.27 0.12 0.16 1.00





HLMA.L 0.18 0.23 0.27 0.20 0.22 0.27 0.53 0.26 0.09 0.32 0.29 1.00




MNG.L 0.43 0.51 0.26 0.38 0.38 0.14 0.27 0.17 0.30 0.28 0.20 0.26 1.00



BA.L 0.34 0.23 0.22 0.20 0.21 0.16 0.11 0.21 0.17 0.21 0.14 0.17 0.28 1.00


LGEN.L 0.50 0.71 0.39 0.53 0.50 0.13 0.37 0.32 0.47 0.39 0.32 0.35 0.58 0.29 1.00

FTSE.100 0.51 0.68 0.61 0.63 0.65 0.46 0.45 0.46 0.45 0.55 0.36 0.50 0.56 0.43 0.73 1.00
S.P.500 0.26 0.38 0.33 0.32 0.31 0.14 0.30 0.19 0.28 0.38 0.21 0.35 0.33 0.19 0.44 0.50 1.00

Stress Tests

Periods of Market Volatility

Total (not annualised) return of the portfolio and benchmarks over five hand-picked historical episodes — one UK-specific, four global (including the April 2025 “Liberation Day” tariff shock) — chosen because they fall after MNG.L’s October 2019 listing, so the UK Dividend portfolio’s own return is computable over the exact same window as the benchmarks. See Methodology Notes for why earlier episodes (the 2008 financial crisis, the 2016 Brexit vote) are excluded.

Show code
ggplot(stress_long, aes(x = Period, y = Return, fill = series)) +
  geom_col(position = position_dodge(width = 0.8), width = 0.7) +
  geom_hline(yintercept = 0, linewidth = 0.4) +
  geom_text(aes(label = percent(Return, accuracy = 0.1)),
            position = position_dodge(width = 0.8), vjust = ifelse(stress_long$Return >= 0, -0.4, 1.2),
            size = 2.8, show.legend = FALSE) +
  scale_fill_manual(values = series_colors) +
  scale_y_continuous(labels = percent) +
  labs(title = "Periods of Market Volatility: UK Dividend vs Benchmark",
       x = NULL, y = "Total Return", fill = NULL) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top", axis.text.x = element_text(angle = 15, hjust = 1))

Individual Investments

Per-Stock Trailing Returns

Annualised total return per holding (YTD is a simple, non-annualised return). A blank cell means the stock’s price history doesn’t genuinely cover that full horizon (see Methodology Notes on MNG.L and AIBG.L).

Show code
per_stock_returns |>
  gt() |>
  fmt_percent(columns = c(YTD, `1Y`, `3Y`, `5Y`, `10Y`), decimals = 2) |>
  sub_missing(columns = everything(), missing_text = "—") |>
  cols_label(Ticker = "Symbol") |>
  tab_header(title = "Per-Stock Trailing Returns",
             subtitle = paste0("Annualised except YTD, as of ", format(to_date, "%d %B %Y")))
Per-Stock Trailing Returns
Annualised except YTD, as of 02 July 2026
Symbol Name Sector YTD 1Y 3Y 5Y 10Y
RR.L Rolls-Royce Holdings Industrials 22.06% 55.11% 112.14% 70.17% 19.70%
LLOY.L Lloyds Banking Group Financials 13.04% 52.78% 36.50% 19.16% 7.81%
RIO.L Rio Tinto Materials 18.59% 62.34% 11.80% 3.65% 11.75%
STAN.L Standard Chartered Financials 12.51% 74.17% 44.40% 35.43% 13.79%
HSBA.L HSBC Holdings Financials 20.90% 63.05% 32.06% 28.26% 12.07%
AZN.L AstraZeneca Health Care 1.94% 33.49% 10.16% 9.58% 12.01%
DPLM.L Diploma Industrials 35.71% 48.98% 34.60% 19.54% 24.59%
NG.L National Grid Utilities 4.65% 17.51% 5.05% 5.61% 0.10%
AIBG.L AIB Group Financials 10.31% 56.22% 39.12% 37.58% 43.25%
ANTO.L Antofagasta Materials 16.04% 97.67% 36.24% 21.22% 22.96%
ADM.L Admiral Group Financials 13.82% 8.38% 19.75% 2.84% 6.02%
HLMA.L Halma Industrials 12.69% 24.83% 21.03% 7.58% 14.69%
MNG.L M&G Financials 15.15% 32.69% 20.16% 8.13%
BA.L BAE Systems Industrials 7.20% 1.67% 27.07% 29.10% 13.58%
LGEN.L Legal & General Financials 9.21% 15.78% 7.80% 1.92% 4.43%

Dividend Yield

Dividend history per stock is retrieved with quantmod::getDividends(). For each horizon, the average annual dividend yield is calculated per stock as total dividends paid over the window divided by (years × average closing price over the window), then combined into a portfolio-level figure using the target weights (renormalised for the 10-year, ex M&G/AIB Group universe).

Show code
yield_table |>
  gt() |>
  fmt_percent(columns = `Avg Annual Dividend Yield`, decimals = 2) |>
  tab_header(title = "Portfolio Average Annual Dividend Yield")
Portfolio Average Annual Dividend Yield
Horizon Avg Annual Dividend Yield
1Y 3.72%
3Y 4.21%
5Y 4.40%
10Y 3.94%

Per-Stock Trailing Dividend Yield

Show code
per_stock_yield |>
  gt() |>
  fmt_percent(columns = c(`1Y`, `3Y`, `5Y`, `10Y`), decimals = 2) |>
  sub_missing(columns = everything(), missing_text = "—") |>
  tab_header(title = "Trailing Average Annual Dividend Yield by Stock")
Trailing Average Annual Dividend Yield by Stock
Ticker 1Y 3Y 5Y 10Y
RR.L 0.81% 0.74% 0.67% 0.76%
LLOY.L 3.93% 4.73% 4.72% 4.39%
RIO.L 4.96% 5.91% 7.91% 7.71%
STAN.L 2.79% 2.84% 2.62% 2.01%
HSBA.L 4.80% 6.67% 6.27% 5.64%
AZN.L 1.79% 2.01% 2.09% 2.57%
DPLM.L 1.10% 1.34% 1.47% 1.56%
NG.L 4.12% 4.78% 4.74% 5.52%
AIBG.L 6.70% 6.52% 5.47% 3.75%
ANTO.L 1.54% 1.52% 2.76% 2.82%
ADM.L 6.34% 5.80% 6.85% 6.38%
HLMA.L 0.64% 0.77% 0.77% 0.83%
MNG.L 7.21% 8.60% 8.88% 6.19%
BA.L 1.88% 2.20% 2.52% 3.00%
LGEN.L 8.55% 8.78% 8.15% 7.30%

Combined Summary

Show code
combined <- left_join(perf_table, yield_table, by = "Horizon") |>
  select(-`Years Covered`)

combined |>
  gt() |>
  fmt_percent(columns = c(`Annualised Return`, `Annualised Volatility`, `Max Drawdown`,
                          `Avg Annual Dividend Yield`), decimals = 2) |>
  fmt_number(columns = `Sharpe Ratio`, decimals = 2) |>
  tab_header(title = "UK Dividend — Summary",
             subtitle = "Annualised total return, risk, and dividend yield by trailing horizon")
UK Dividend — Summary
Annualised total return, risk, and dividend yield by trailing horizon
Horizon Annualised Return Annualised Volatility Sharpe Ratio Max Drawdown Avg Annual Dividend Yield
1Y 48.49% 17.20% 2.37 11.27% 3.72%
3Y 35.13% 15.59% 2.01 16.22% 4.21%
5Y 24.49% 17.24% 1.35 16.37% 4.40%
10Y 15.41% 18.47% 0.86 33.76% 3.94%

Methodology Notes & Caveats

  • Total return basis. Growth charts and CAGR figures use adjusted (dividend- and split-adjusted) closing prices, so dividends are treated as reinvested. The dividend yield table is a separate, cash-yield-on-price measure and should not be added to the total return figures (it is already embedded in them).
  • Rebalancing. The backtest rebalances back to the target weights every quarter, with no transaction costs modelled.
  • 10-year universe. M&G plc and AIB Group are excluded from the 10-year figures only, because they were not independently listed for the full period; their weight is redistributed proportionally across the other 13 stocks for that calculation only.
  • Currency. All prices and dividends are in GBX (pence), as quoted on the London Stock Exchange; no FX effects apply since the whole portfolio is GBP-denominated. The S&P 500 comparison line uses its native USD level rebased to 100 — it is a local-currency price comparison only and does not reflect what a GBP investor would actually have earned after conversion.
  • Up/down capture and holdings correlation use daily returns, not the monthly returns vendors like Morningstar conventionally use for these metrics, so figures here won’t match vendor-reported capture ratios exactly.
  • Stress-test period selection is a judgment call, not an algorithm. The four windows are well-known market episodes with widely reported start/end dates, chosen by hand. They’re constrained to fall after MNG.L’s October 2019 listing so the portfolio’s own return is computable over the identical window used for the benchmarks; this excludes earlier, arguably more consequential episodes such as the 2008 financial crisis or the June 2016 Brexit referendum vote, which predate the full portfolio’s computable history.
  • Key Observations are descriptive, not advice. They restate figures already computed elsewhere in the report in plain language and are not investment recommendations.
  • Equity style box methodology. This is not the official Morningstar or FTSE Russell style classification (both incorporate valuation multiples and earnings growth, neither of which this pipeline fetches). Style (Value/Core/Growth) is a computed, reproducible proxy based on trailing 5-year average dividend yield terciles. Size (Large/Mid/Small) is a one-off manual classification based on general knowledge of these 15 companies as of this report’s writing, not a live market-capitalisation feed; all 15 currently classify as Large, so the Mid and Small rows of the style grid are genuinely empty rather than a display bug. Both classifications are static until this code is edited — they won’t update if a stock’s yield rank or market-cap tier changes. The Style Map’s ownership zone is an illustrative ellipse (weighted spread of the 15 holdings’ style/size positions) — not Morningstar’s proprietary ownership-zone methodology, and it will span most of the Value-Growth axis more or less by construction, since tercile splits guarantee roughly equal Value/Core/Growth group sizes. The FTSE 100 and S&P 500 points on that same map are not computed from constituent data at all (this pipeline has no per-constituent classification for either index) — they’re placed at a rough, commonly-cited style characterization instead, and should be read as illustrative context, not a measured comparison.
  • Stress-test period selection is a judgment call, not an algorithm (continued from above): the 2025 Liberation Day window covers 2 April – 8 April 2025, capturing the initial tariff-announcement selloff but not the 9 April rally that followed the 90-day pause announcement; it was chosen to isolate the shock itself rather than the round trip.
  • Sections intentionally not reproduced from index-provider-style template reports. Equity regional exposure, sector allocation relative to the FTSE 100’s own constituent-level sector weights, fund-style percentile rankings, fund expense ratios, and forward-looking scenario shocks all require data this pipeline doesn’t source — FTSE 100 constituent-level sector weights, a comparable fund universe, per-holding expense ratios (these are individual stocks, not funds, so there is no fund-level expense ratio to report), and a proprietary scenario model, respectively. Rather than approximate these with placeholder or hand-typed figures, they’re omitted; the historical Stress Tests section serves a similar risk-communication purpose to a forward-looking scenario table, using real, computed data instead.
  • Data source. Prices and dividends come from Yahoo Finance via quantmod/portfolioBacktest; isolated data glitches were identified and patched during validation (see Data & Backtest → Cleaning) but further undetected anomalies cannot be fully ruled out.