R version 4.5.2 (2025-10-31)
Platform: x86_64-pc-linux-gnu
Running under: Ubuntu 24.04.3 LTS
Matrix products: default
BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
locale:
[1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8
[4] LC_COLLATE=C.UTF-8 LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8
[7] LC_PAPER=C.UTF-8 LC_NAME=C LC_ADDRESS=C
[10] LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
time zone: UTC
tzcode source: system (glibc)
attached base packages:
[1] stats graphics grDevices utils datasets methods base
loaded via a namespace (and not attached):
[1] compiler_4.5.2 fastmap_1.2.0 cli_3.6.5 tools_4.5.2
[5] htmltools_0.5.9 otel_0.2.0 yaml_2.3.12 rmarkdown_2.30
[9] knitr_1.51 jsonlite_2.0.0 xfun_0.55 digest_0.6.39
[13] rlang_1.1.6 evaluate_1.0.5
ETF Data Dashboard (Shinylive)
This vignette presents an interactive Shiny dashboard, powered by Shinylive and WebR, allowing you to explore and filter ETF data directly in your browser. Live HTTP requests are blocked by the browser sandbox, so the dashboard uses a cached snapshot generated during CI from the curated universe list and incremental price downloads. See docs/wiki/ETF_Data_Sources.md for details.
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 800
webr_repo <- "https://johngavin.github.io/etf-data/"
options(repos = c(CRAN = webr_repo))
ensure_webr_packages <- function(pkgs) {
installed <- rownames(installed.packages())
missing <- setdiff(pkgs, installed)
if (length(missing) == 0) {
return(invisible(TRUE))
}
if (!requireNamespace("webr", quietly = TRUE)) {
stop("Missing packages in WebR session: ", paste(missing, collapse = ", "))
}
webr::install(missing, repos = webr_repo)
}
ensure_webr_packages(c(
"munsell",
"scales",
"gtable",
"isoband",
"farver",
"labeling",
"RColorBrewer",
"viridisLite",
"S7"
))
library(shiny)
library(dplyr)
library(ggplot2)
library(DT)
library(htmltools)
library(httr2)
library(janitor)
library(readr)
library(stringr)
# --- Helper Functions (Embedded for WASM) ---
load_snapshot <- function() {
if (!file.exists("vignette_data.rds")) {
return(NULL)
}
readRDS("vignette_data.rds")
}
# Cached screener snapshot for browser-safe use
get_cached_screener_data <- function(min_aum_gbp = 0, max_ter = Inf) {
snap <- load_snapshot()
if (is.null(snap) || !all(c("metadata", "universe") %in% names(snap))) {
return(dplyr::tibble())
}
meta <- snap$metadata
if (!"aum_num" %in% names(meta) && "aum_text" %in% names(meta)) {
meta <- meta |> dplyr::mutate(aum_num = readr::parse_number(.data$aum_text))
}
if (!"ter_num" %in% names(meta) && "ter_text" %in% names(meta)) {
meta <- meta |> dplyr::mutate(ter_num = readr::parse_number(.data$ter_text))
}
meta |>
dplyr::left_join(snap$universe, by = "isin") |>
dplyr::filter(
is.na(.data$aum_num) | .data$aum_num >= min_aum_gbp,
is.na(.data$ter_num) | .data$ter_num <= max_ter
)
}
# --- Data (Embedded for WASM) ---
# Static seed list fallback if the cached snapshot is missing.
# We attempt to read the file from the current directory (resources).
get_etf_universe_static <- function(n = Inf) {
snap <- load_snapshot()
if (!is.null(snap$universe)) {
if (is.finite(n) && n < nrow(snap$universe)) {
return(head(snap$universe, n))
}
return(snap$universe)
}
if (file.exists("seed_universe.csv")) {
seed_universe <- readr::read_csv("seed_universe.csv", show_col_types = FALSE)
if (is.finite(n) && n < nrow(seed_universe)) {
return(head(seed_universe, n))
}
return(seed_universe)
}
NULL
}
# --- App UI & Server ---
# UI definition
ui <- htmltools::tagList(
shiny::fluidPage(
titlePanel("ETF Universe Explorer"),
sidebarLayout(
sidebarPanel(
p("Explore the universe of ETFs."),
verbatimTextOutput("snapshotInfo"),
selectInput("currencyFilter",
"Filter by Currency:",
choices = c("All", "GBP", "USD", "EUR"),
selected = "All"),
sliderInput("minAUM",
"Minimum AUM (millions):",
min = 0,
max = 10000,
value = 0,
step = 100)
),
mainPanel(
h3("Filtered ETF Data"),
DT::dataTableOutput("etfTable"),
h3("AUM vs TER (Live Data Only)"),
plotOutput("aumTerPlot")
)
)
)
)
# Server logic
server <- function(input, output, session) {
# Load data
universe_data <- reactiveVal(NULL)
output$snapshotInfo <- renderText({
snap <- load_snapshot()
if (is.null(snap)) {
return("Snapshot: not available")
}
updated <- if (!is.null(snap$generated_at)) format(snap$generated_at) else "unknown"
source <- if (!is.null(snap$source)) snap$source else "cached"
rows <- if (!is.null(snap$metadata)) nrow(snap$metadata) else 0
paste0("Snapshot: ", updated, " | Source: ", source, " | Rows: ", rows)
})
observe({
cached <- get_cached_screener_data(min_aum_gbp = 0, max_ter = Inf)
if (nrow(cached) > 0) {
universe_data(cached)
return()
}
static <- get_etf_universe_static()
if (!is.null(static)) {
static <- static %>% mutate(currency = as.character(currency))
universe_data(static)
return()
}
universe_data(dplyr::tibble())
})
output$etfTable <- DT::renderDataTable({
data <- universe_data()
req(data)
if (input$currencyFilter != "All" && "currency" %in% colnames(data)) {
data <- data %>% filter(currency == input$currencyFilter)
}
cols <- c("ticker", "name", "currency", "aum_text", "ter_text", "aum_num", "ter_num")
cols <- intersect(cols, colnames(data))
data %>%
select(all_of(cols)) %>%
DT::datatable(options = list(pageLength = 10))
})
output$aumTerPlot <- renderPlot({
data <- universe_data()
req(data)
if (input$currencyFilter != "All" && "currency" %in% colnames(data)) {
data <- data %>% filter(currency == input$currencyFilter)
}
# Only plot if we have AUM and TER (from live fetch)
if ("aum_num" %in% colnames(data) && "ter_num" %in% colnames(data)) {
plot_data <- data %>%
filter(!is.na(aum_num) & !is.na(ter_num)) %>%
filter(aum_num >= input$minAUM)
if (nrow(plot_data) > 0) {
ggplot(plot_data, aes(x = aum_num, y = ter_num)) +
geom_point(aes(color = currency), alpha = 0.7) +
scale_x_log10(labels = scales::label_number(suffix = "M", scale_cut = scales::cut_short_scale())) +
scale_y_log10(labels = scales::label_percent(scale = 1)) +
labs(title = "AUM vs TER", x = "Assets Under Management", y = "Total Expense Ratio (%)") +
theme_minimal()
} else {
ggplot() + annotate("text", x = 0.5, y = 0.5, label = "No data to plot") + theme_void()
}
} else {
ggplot() + annotate("text", x = 0.5, y = 0.5, label = "AUM or TER not available in snapshot") + theme_void()
}
})
}
shinyApp(ui, server)