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 tablecompany_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:
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.
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 inc("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 <-1while (run < n &&!is.na(v[run +1]) && v[run +1] == first) run <- run +1index(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 constraintsfull_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 weightstickers_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_datafor (nm inc("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-indata_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.
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]]$wealthcolnames(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.015ggplot(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")}
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:
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:
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):
Dividend history and per-stock trailing total returns are calculated here, per ticker, for reuse in the Dividend Yield and Individual Investments sections.
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”:
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))
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)))
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))
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)))
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))
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).
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).
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).
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.
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$Weightwx <- style_positions$style_numwy <- style_positions$size_numcentroid_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 Largemake_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.
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.
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).
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).
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.