Skip to contents

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)

Session Info

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