R/roll_series.R

Defines functions .expanding_stat_raw .expanding_stat .validate_ytd_calendar .observation_years .roll_ytd .use_2xn .roll_change .roll_fixed_raw .roll_fixed .roll_one .blank_short_windows .min_obs .inform_rolling .warn_ytd_partial_start .ts_start_period .validate_chain_scale .warn_ignored_args .validate_rolling_params .roll_series_list roll_series

Documented in roll_series

#' Rolling aggregations for time series objects
#'
#' @description
#' Compute rolling and year-to-date aggregations of a time series. Unlike
#' [extract_trends()], which estimates a trend in the units of the series, these
#' are *aggregations*: a 12-month rolling sum is a 12-month total, not a level
#' estimate. The two families are kept separate for that reason, so rolling
#' results are not accepted by [detrend_series()].
#'
#' @param ts_data A time series object (`ts`, `xts`, or `zoo`) or any object
#'   convertible via tsbox.
#' @param stats Character vector of rolling statistics. Options: `"sum"`
#'   (rolling total of flows), `"chain"` (compound accumulation of rates,
#'   `prod(1 + r) - 1`), `"change"` (change of a level over `window` periods,
#'   `x[t] / x[t - window] - 1`), `"mean"`, `"sd"`, `"min"`, `"max"`. Default
#'   is `"sum"`.
#' @param window Window length in periods, or the lag for `"change"`. If
#'   `NULL`, defaults to the series frequency (12 for monthly, 4 for
#'   quarterly). A numeric vector runs the statistic once per window value.
#'   Alternatively, the string `"ytd"` computes an expanding year-to-date
#'   accumulation that resets each January (or Q1), and `"all"` an expanding
#'   accumulation from the first observation. Numeric and character windows
#'   cannot be mixed in one call, and `"change"` needs a numeric window.
#' @param align Alignment of the window relative to the output position:
#'   `"right"` (default, causal — uses the current and preceding observations),
#'   `"center"`, or `"left"`. Right alignment is the convention for accumulated
#'   economic indicators. Ignored by `"change"` and by the expanding windows
#'   `"ytd"` and `"all"`. An even window has no exact centre; see Details for
#'   how each statistic handles that.
#' @param percent Only used by `stats = "chain"` and `stats = "change"`. For
#'   `"chain"`, if `FALSE` (default), rates are assumed to be decimals (0.005
#'   for 0.5%). If `TRUE`, rates are assumed to be percentages (0.5 for 0.5%)
#'   and the result is returned in percent. For `"change"`, `TRUE` returns the
#'   change in percent instead of as a decimal.
#' @param na_rm If `TRUE`, missing values are ignored within each window. The
#'   default `FALSE` propagates `NA`, so an incomplete window yields `NA`. A
#'   window holding no observed values yields `NA` either way, as does a
#'   window holding one value for `"sd"`. For even centered means, observed
#'   weights are renormalized under `na_rm = TRUE`; boundary padding is kept.
#'   `"change"` ignores it: a missing value at either end yields `NA`.
#' @param .quiet If `TRUE`, suppress informational messages.
#'
#' @return If a single statistic and a single window are requested, a `ts`
#'   object. Otherwise a named list of `ts` objects with names of the form
#'   `{stat}_{window}` (e.g. `sum_12`, `chain_ytd`, `change_12`).
#'
#' @importFrom cli cli_abort cli_inform cli_warn
#' @importFrom RcppRoll roll_sum roll_sd roll_min roll_max roll_prod
#' @importFrom stats is.ts frequency start time ts sd
#'
#' @details
#' `stats = "sum"` and `stats = "chain"` answer the same question for different
#' kinds of series. For a flow measured in levels (units sold, jobs created),
#' the 12-month accumulation is the sum. For a series that is already a rate of
#' change (monthly inflation, monthly returns), summing is only an
#' approximation; the correct accumulation compounds the rates:
#' \deqn{(1 + r_1)(1 + r_2)\cdots(1 + r_k) - 1}
#'
#' `stats = "change"` goes the other way, from a level (an index, a price, real
#' income) to its rate of change over `window` periods. Chaining the
#' one-period changes over `k` periods gives back the `k`-period change. The
#' lag counts periods on the calendar grid for monthly, quarterly and annual
#' series, and observations for daily and weekly series.
#'
#' Note that a rolling sum is proportional to the simple moving average
#' available through [extract_trends()]: `roll_series(x, "sum", window = k)`
#' equals `k` times `extract_trends(x, "ma", window = k, align = "right")`. The
#' rolling version is the one to reach for when the accumulated quantity is
#' itself the number of interest. The two part company for an even `window`
#' under `align = "center"`, where the moving average is weighted and the sum
#' is not.
#'
#' An even window centred on an observation has one more period on one side
#' than the other. `"mean"` resolves this the way the `ma` trend method does,
#' with the 2xN filter that puts half weight on the two endpoints, so
#' `roll_series(x, "mean", window = k, align = "center")` matches
#' `extract_trends(x, "ma", window = k, align = "center")`. The other
#' statistics have no such correction and use a window with one extra period
#' after the anchor.
#'
#' @seealso [augment_rolling()] for the data frame interface,
#'   [extract_trends()] for trend estimation.
#'
#' @examples
#' # 12-month rolling sum of vehicle production
#' prod_ts <- df_to_ts(vehicles, value_col = "production", frequency = 12)
#' roll_series(prod_ts, "sum", window = 12)
#'
#' # Accumulated growth over 12 months, from monthly rates in percent
#' ibc_ts <- df_to_ts(ibcbr, value_col = "index", frequency = 12)
#' rates <- roll_series(ibc_ts, "change", window = 1, percent = TRUE)
#' roll_series(rates, "chain", window = 12, percent = TRUE)
#'
#' # Year-to-date accumulation, resetting each January
#' roll_series(rates, "chain", window = "ytd", percent = TRUE)
#'
#' # Cumulative growth since the start of the series
#' roll_series(rates, "chain", window = "all", percent = TRUE)
#'
#' # 12-month change of the index, in percent
#' roll_series(ibc_ts, "change", window = 12, percent = TRUE)
#'
#' # Several statistics and windows at once
#' roll_series(prod_ts, stats = c("sum", "sd"), window = c(3, 12))
#'
#' @export
roll_series <- function(
  ts_data,
  stats = "sum",
  window = NULL,
  align = "right",
  percent = FALSE,
  na_rm = FALSE,
  .quiet = FALSE
) {
  results <- .roll_series_list(
    ts_data = ts_data,
    stats = stats,
    window = window,
    align = align,
    percent = percent,
    na_rm = na_rm,
    .quiet = .quiet
  )

  if (length(results) == 1) {
    return(results[[1]])
  }

  return(results)
}

#' Rolling aggregations, always as a named list
#'
#' @description The list is the real return value; `roll_series()` only unwraps
#' a single result for convenience. `augment_rolling()` calls this instead, so
#' the `{stat}_{window}` names that become column names are built in one place.
#'
#' `.check_inputs` exists for the grouped path, which runs the scale and
#' calendar checks once on the whole input rather than once per group.
#' @noRd
.roll_series_list <- function(
  ts_data,
  stats = "sum",
  window = NULL,
  align = "right",
  percent = FALSE,
  na_rm = FALSE,
  .quiet = FALSE,
  .check_inputs = TRUE
) {
  # Convert to ts object using tsbox if needed
  if (!stats::is.ts(ts_data)) {
    tryCatch(
      {
        ts_data <- tsbox::ts_ts(ts_data)
      },
      error = function(e) {
        cli::cli_abort(c(
          "Failed to convert input to time series object.",
          "i" = "Input must be convertible to ts via the tsbox package.",
          "x" = "Error: {e$message}"
        ))
      }
    )
  }

  freq <- stats::frequency(ts_data)
  window <- .validate_rolling_params(
    stats = stats,
    window = window,
    align = align,
    percent = percent,
    na_rm = na_rm,
    freq = freq,
    n = length(ts_data)
  )

  if (.check_inputs) {
    .warn_ignored_args(stats, window, align, percent)
    .validate_chain_scale(as.numeric(ts_data), stats, percent)
    if (identical(window, "ytd")) {
      .validate_ytd_calendar(ts_data, freq)
      .warn_ytd_partial_start(
        .ts_start_period(ts_data),
        freq,
        attr(ts_data, "trendseries_dates")[1]
      )
    }
  }

  # One result per stat x window combination
  results <- list()
  for (w in window) {
    for (stat in stats) {
      if (!.quiet) {
        .inform_rolling(stat, w, align)
      }
      results[[paste0(stat, "_", w)]] <- .roll_one(
        ts_data = ts_data,
        stat = stat,
        window = w,
        align = align,
        percent = percent,
        na_rm = na_rm
      )
    }
  }

  return(results)
}

## Validation ----------------------------------------------------------------

#' Validate rolling arguments and normalise `window`
#'
#' @description Returns the normalised window, either a numeric vector or one
#' of the strings `"ytd"` and `"all"`. Aborts on invalid input.
#' @noRd
.validate_rolling_params <- function(
  stats,
  window,
  align,
  percent,
  na_rm,
  freq,
  n
) {
  if (!is.character(stats) || length(stats) == 0 || anyNA(stats)) {
    cli::cli_abort("{.arg stats} must be a non-empty character vector")
  }

  valid_stats <- .valid_rolling_stats()
  invalid <- setdiff(stats, valid_stats)
  if (length(invalid) > 0) {
    cli::cli_abort(c(
      "Invalid rolling statistic{?s}: {.val {invalid}}",
      "i" = "Available statistics: {.val {valid_stats}}"
    ))
  }

  if (anyDuplicated(stats) > 0) {
    cli::cli_abort("{.arg stats} must not contain duplicates")
  }

  if (!align %in% c("left", "center", "right")) {
    cli::cli_abort(
      "{.arg align} must be one of 'left', 'center', or 'right', got {.val {align}}"
    )
  }

  if (!is.logical(percent) || length(percent) != 1 || is.na(percent)) {
    cli::cli_abort(
      "{.arg percent} must be a single {.code TRUE} or {.code FALSE}"
    )
  }

  if (!is.logical(na_rm) || length(na_rm) != 1 || is.na(na_rm)) {
    cli::cli_abort(
      "{.arg na_rm} must be a single {.code TRUE} or {.code FALSE}"
    )
  }

  # Both the frequency default and "ytd" need a calendar to divide the year
  if (is.null(window) || identical(window, "ytd")) {
    if (freq < 2) {
      what <- if (is.null(window)) "The default {.arg window}" else "{.val ytd}"
      cli::cli_abort(c(
        paste(what, "is not available for series with frequency {freq}."),
        "i" = "A seasonal frequency is needed to divide the year into periods.",
        "i" = "Supply a numeric {.arg window} instead."
      ))
    }
  }

  if (is.null(window)) {
    return(freq)
  }

  if (is.character(window)) {
    if (length(window) != 1 || !window %in% c("ytd", "all")) {
      cli::cli_abort(c(
        "The only character values accepted by {.arg window} are {.val ytd} and {.val all}.",
        "i" = "Supply one of them on its own, not combined with other windows."
      ))
    }
    if ("change" %in% stats) {
      cli::cli_abort(c(
        "{.val change} needs a numeric {.arg window}, got {.val {window}}.",
        "i" = "The window is the lag the change is measured over."
      ))
    }
    return(window)
  }

  if (!is.numeric(window) || length(window) == 0 || anyNA(window)) {
    cli::cli_abort(
      "{.arg window} must be a positive integer, a vector of positive integers, {.val ytd}, or {.val all}"
    )
  }

  # A one-period change is month-on-month; a one-period sum is the series
  min_window <- if (all(stats == "change")) 1 else 2
  if (any(window < min_window) || any(window != round(window))) {
    cli::cli_abort(
      "{.arg window} values must be whole numbers of at least {min_window}, got {.val {window}}"
    )
  }

  if (anyDuplicated(window) > 0) {
    cli::cli_abort("{.arg window} must not contain duplicates")
  }

  too_long <- window[window > n]
  if (length(too_long) > 0) {
    cli::cli_abort(c(
      "Rolling window cannot exceed the series length of {n} observations.",
      "i" = "Too large: {.val {too_long}}"
    ))
  }

  return(as.integer(window))
}

#' Warn about arguments the requested combination ignores
#'
#' @description `align` has no meaning for an expanding window or for a
#' change, which always looks back. `percent` is read only by `chain` and
#' `change`. Both were dropped silently before.
#' @noRd
.warn_ignored_args <- function(stats, window, align, percent) {
  if (!identical(align, "right")) {
    if (is.character(window)) {
      cli::cli_warn(c(
        "{.arg align} is ignored when {.arg window} is {.val {window}}.",
        "i" = "An expanding window grows from its first observation."
      ))
    } else if ("change" %in% stats) {
      cli::cli_warn(c(
        "{.arg align} is ignored by {.val change}.",
        "i" = "A change always compares a value with an earlier one."
      ))
    }
  }

  if (isTRUE(percent) && !any(c("chain", "change") %in% stats)) {
    cli::cli_warn(c(
      "{.arg percent} is ignored by {.val {stats}}.",
      "i" = "Only {.val chain} and {.val change} read it."
    ))
  }

  return(invisible(NULL))
}

#' Largest plausible decimal rate before the series looks like percentages
#' @noRd
.CHAIN_DECIMAL_MAX <- 0.5

#' Largest percentage-point rate before the series looks like decimals
#' @noRd
.CHAIN_PERCENT_MIN <- 0.05

#' Warn when chained rates look mis-scaled
#'
#' @description `chain` is the one statistic where a 100x scaling mistake is
#' silent, so the plausibility of the assumed scale is checked against the
#' largest absolute rate in the series. A decimal rate above 50% per period, or
#' a percentage-point rate that never reaches 0.05, almost always means
#' `percent` was set the wrong way round. Both thresholds are deliberately
#' loose: the check only warns, and never changes the result.
#'
#' Not gated on `.quiet`, which suppresses narration rather than correctness
#' signals. The grouped path runs this once on the whole input.
#' @noRd
.validate_chain_scale <- function(values, stats, percent) {
  if (!"chain" %in% stats) {
    return(invisible(NULL))
  }

  v <- as.numeric(values)
  v <- v[!is.na(v)]
  if (length(v) == 0) {
    return(invisible(NULL))
  }

  peak <- max(abs(v))

  if (!percent && peak > .CHAIN_DECIMAL_MAX) {
    cli::cli_warn(c(
      "Values passed to {.val chain} look like percentages, not decimals.",
      "i" = "With {.code percent = FALSE} the largest rate ({signif(peak, 4)}) is read as {signif(peak * 100, 4)}% per period.",
      "i" = "Set {.code percent = TRUE} if the series is in percentage points."
    ))
  }

  if (percent && peak < .CHAIN_PERCENT_MIN) {
    cli::cli_warn(c(
      "Values passed to {.val chain} look like decimals, not percentages.",
      "i" = "With {.code percent = TRUE} the largest rate ({signif(peak, 4)}) is read as {signif(peak, 4)}% per period.",
      "i" = "Set {.code percent = FALSE} if the series holds decimal rates."
    ))
  }

  return(invisible(NULL))
}

#' Period of the year the series starts in, counting from 1
#' @noRd
.ts_start_period <- function(ts_data) {
  return(as.integer(stats::start(ts_data)[2]))
}

#' Warn when a year-to-date accumulation starts mid-year
#'
#' @description The first year of a series beginning in, say, March accumulates
#' from March, not from January, so it is not comparable with the years that
#' follow. The values are left alone; the reader is the one who has to know.
#' @noRd
.warn_ytd_partial_start <- function(start_period, freq, start_date = NULL) {
  unit <- .frequency_unit(freq)

  # Without a calendar period there is no period number worth reporting, and
  # "period 170" tells a reader of a daily series nothing. The start date does
  if (is.null(unit)) {
    if (
      is.null(start_date) ||
        is.na(start_date) ||
        lubridate::month(start_date) == 1
    ) {
      return(invisible(NULL))
    }

    cli::cli_warn(c(
      "Series starts on {format(start_date)}, so the first year is incomplete.",
      "i" = "Its year-to-date values accumulate from that date onwards, not from the start of the year.",
      "i" = "They are not comparable with later years."
    ))

    return(invisible(NULL))
  }

  if (is.na(start_period) || start_period == 1) {
    return(invisible(NULL))
  }

  cli::cli_warn(c(
    "Series starts at {unit} {start_period}, so the first year is incomplete.",
    "i" = "Its year-to-date values accumulate from {unit} {start_period} onwards, not from the start of the year.",
    "i" = "They are not comparable with later years."
  ))

  return(invisible(NULL))
}

#' Report the rolling computation about to run
#' @noRd
.inform_rolling <- function(stat, window, align) {
  if (identical(window, "ytd")) {
    cli::cli_inform("Computing year-to-date {stat}")
  } else if (identical(window, "all")) {
    cli::cli_inform("Computing expanding {stat} over the whole series")
  } else if (stat == "change") {
    cli::cli_inform("Computing {window}-period change")
  } else if (stat == "mean" && .use_2xn(window, align)) {
    cli::cli_inform(
      "Computing 2x{window}-period rolling mean (auto-adjusted for even-window centering)"
    )
  } else {
    cli::cli_inform(
      "Computing {window}-period rolling {stat} with {align} alignment"
    )
  }

  return(invisible(NULL))
}

## Computation ---------------------------------------------------------------

#' Observations a statistic needs before it has an answer
#' @noRd
.min_obs <- function(stat) {
  return(if (stat == "sd") 2L else 1L)
}

#' Blank windows that hold too few observations
#'
#' @description With `na_rm = TRUE` the backends return their identity element
#' for a window with nothing in it: `0` for a sum, `1` for a product, `Inf` and
#' `-Inf` for a minimum and a maximum, `NaN` for a mean. None of those are
#' answers, so every statistic is blanked by the same rule. With
#' `na_rm = FALSE` the backends already propagate `NA` and this is a no-op,
#' which is why it runs unconditionally rather than on a branch.
#' @noRd
.blank_short_windows <- function(out, counts, stat) {
  short <- is.na(counts) | counts < .min_obs(stat)
  out[short] <- NA_real_

  return(out)
}

#' Compute one rolling statistic and return it as a ts
#' @noRd
.roll_one <- function(ts_data, stat, window, align, percent, na_rm) {
  v <- as.numeric(ts_data)

  if (identical(window, "ytd")) {
    result <- .roll_ytd(v, ts_data, stat, percent, na_rm)
  } else if (identical(window, "all")) {
    result <- .expanding_stat(v, stat, percent, na_rm)
  } else if (stat == "change") {
    result <- .roll_change(v, window, percent)
  } else {
    result <- .roll_fixed(v, stat, window, align, percent, na_rm)
  }

  out <- stats::ts(
    result,
    start = stats::start(ts_data),
    frequency = stats::frequency(ts_data)
  )
  return(out)
}

#' Fixed-width rolling window via RcppRoll
#' @noRd
.roll_fixed <- function(v, stat, window, align, percent, na_rm) {
  if (stat == "mean" && .use_2xn(window, align)) {
    return(.ma_2xn(v, window, na_rm))
  }
  counts <- RcppRoll::roll_sum(
    as.numeric(!is.na(v)),
    n = window,
    align = align,
    fill = NA
  )

  out <- .roll_fixed_raw(v, stat, window, align, percent, na_rm)

  return(.blank_short_windows(out, counts, stat))
}

#' Dispatch a fixed-width window to its backend, before the window count check
#' @noRd
.roll_fixed_raw <- function(v, stat, window, align, percent, na_rm) {
  if (stat == "chain") {
    rates <- if (percent) v / 100 else v
    prod <- RcppRoll::roll_prod(
      1 + rates,
      n = window,
      align = align,
      fill = NA,
      na.rm = na_rm
    )
    out <- prod - 1
    if (percent) {
      out <- out * 100
    }
    return(out)
  }

  roll_fun <- switch(
    stat,
    "sum" = RcppRoll::roll_sum,
    "mean" = RcppRoll::roll_mean,
    "sd" = RcppRoll::roll_sd,
    "min" = RcppRoll::roll_min,
    "max" = RcppRoll::roll_max
  )

  out <- roll_fun(v, n = window, align = align, fill = NA, na.rm = na_rm)
  return(out)
}

#' Change over `lag` periods, as a decimal or in percent
#'
#' @description The series sits on a complete period grid, so shifting by
#' position compares each value with the one `lag` periods earlier.
#' @noRd
.roll_change <- function(v, lag, percent) {
  n <- length(v)
  prev <- c(rep(NA_real_, min(lag, n)), v[seq_len(max(n - lag, 0))])
  out <- v / prev - 1
  if (percent) {
    out <- out * 100
  }

  return(out)
}

#' Does this window and alignment call for the 2xN correction?
#' @noRd
.use_2xn <- function(window, align) {
  if (is.character(window)) {
    return(FALSE)
  }

  return(window %% 2 == 0 && align == "center")
}

#' Expanding year-to-date accumulation, resetting at each new year
#' @noRd
.roll_ytd <- function(v, ts_data, stat, percent, na_rm) {
  year <- .observation_years(ts_data)

  out <- rep(NA_real_, length(v))
  for (y in unique(year)) {
    idx <- which(year == y)
    out[idx] <- .expanding_stat(v[idx], stat, percent, na_rm)
  }

  return(out)
}

#' Calendar year of every observation in a series
#'
#' @description A year-to-date accumulation has to reset on the calendar, so
#' each observation needs its year. Where the series came from a data frame the
#' dates are recorded on it and the year is read from them.
#'
#' Otherwise the year comes from the `ts` time index, which is exact only when
#' every period is a fixed fraction of a year. A daily series is the case where
#' it is not: `time()` advances a year every 252 observations while the
#' calendar advances on holidays and weekends too, so the reset drifts a little
#' further from January with each year that passes. `.validate_ytd_calendar()`
#' rejects that case before it reaches here.
#' @noRd
.observation_years <- function(ts_data) {
  dates <- attr(ts_data, "trendseries_dates")

  if (!is.null(dates) && length(dates) == length(ts_data)) {
    return(lubridate::year(dates))
  }

  return(as.integer(floor(stats::time(ts_data) + 1e-8)))
}

#' Require a recoverable calendar before accumulating year to date
#' @noRd
.validate_ytd_calendar <- function(ts_data, freq, call = rlang::caller_env()) {
  known_dates <- !is.null(attr(ts_data, "trendseries_dates"))
  if (known_dates || !is.null(.frequency_unit(freq))) {
    return(invisible(NULL))
  }

  cli::cli_abort(
    c(
      "{.val ytd} is not available for a {.cls ts} of frequency {freq}.",
      "i" = "Its time index advances a year every {freq} observations, which drifts from the calendar.",
      "i" = "Pass the series as a data frame to {.fn augment_rolling}, or use a numeric {.arg window}."
    ),
    call = call
  )
}

#' Expanding-window statistic over a year's or the whole series' observations
#' @noRd
.expanding_stat <- function(v, stat, percent, na_rm) {
  out <- .expanding_stat_raw(v, stat, percent, na_rm)

  return(.blank_short_windows(out, cumsum(!is.na(v)), stat))
}

#' Expanding-window statistic before the window count check
#' @noRd
.expanding_stat_raw <- function(v, stat, percent, na_rm) {
  n <- length(v)

  if (stat == "sd") {
    # ponytail: no closed cumulative form, so O(n^2); fine until a long daily
    # series under window = "all" needs a running-variance update
    out <- vapply(
      seq_len(n),
      function(i) {
        w <- v[seq_len(i)]
        if (!na_rm && anyNA(w)) {
          return(NA_real_)
        }
        w <- w[!is.na(w)]
        if (length(w) < 2) {
          return(NA_real_)
        }
        return(stats::sd(w))
      },
      numeric(1)
    )
    return(out)
  }

  if (stat == "chain") {
    rates <- if (percent) v / 100 else v
    x <- 1 + rates
    if (na_rm) {
      x[is.na(x)] <- 1
    }
    out <- cumprod(x) - 1
    if (percent) {
      out <- out * 100
    }
    return(out)
  }

  if (stat == "sum") {
    x <- v
    if (na_rm) {
      x[is.na(x)] <- 0
    }
    return(cumsum(x))
  }

  if (stat == "mean") {
    x <- v
    if (na_rm) {
      observed <- !is.na(x)
      x[!observed] <- 0
      return(cumsum(x) / cumsum(observed))
    }
    return(cumsum(x) / seq_len(n))
  }

  # min and max
  if (!na_rm) {
    return(if (stat == "min") cummin(v) else cummax(v))
  }

  compare <- if (stat == "min") min else max
  out <- Reduce(
    function(acc, new) {
      if (is.na(new)) {
        return(acc)
      }
      if (is.na(acc)) {
        return(new)
      }
      return(compare(acc, new))
    },
    v,
    accumulate = TRUE
  )
  return(as.numeric(unlist(out)))
}

Try the trendseries package in your browser

Any scripts or data that you put into this service are public.

trendseries documentation built on Oct. 1, 2026, 5:10 p.m.