From 714ba210f467d1e60ec9f74bac2cc1ea7fb8b918 Mon Sep 17 00:00:00 2001 From: Michael Caselli Date: Tue, 14 Jul 2026 14:56:45 -0400 Subject: [PATCH 1/8] Add period-completeness helpers Provides a shared, business-day-aware toolkit for reporting on partial periods, replacing logic that was re-implemented per project. `period_is_complete()` tests whether the period (week/month/quarter/ year) containing each date is complete as of a cutoff, using a QuantLib calendar (default "WeekendsOnly"; "UnitedStates" for holiday accuracy; "Null" for pure calendar-day semantics). Also adds `period_start_date()`/`period_end_date()` (canonical calendar boundaries), `filter_complete_periods()` (drops the in-progress trailing period; as_of defaults to the data max and an earlier as_of is an error), `last_complete_period_start()`/`last_complete_period_end()`, and `scale_alpha_logical()` for fading incomplete periods in ggplots. Completeness is judged on business days only; returned dates are always canonical calendar boundaries. NA dates propagate to NA and are dropped by filter_complete_periods(). Vectorizes correctly over a span of dates. Co-Authored-By: Claude Opus 4.8 --- NAMESPACE | 7 + NEWS.md | 22 ++ R/period_completeness.R | 352 ++++++++++++++++++++++ man/filter_complete_periods.Rd | 55 ++++ man/last_complete_period.Rd | 56 ++++ man/period_bounds.Rd | 54 ++++ man/period_is_complete.Rd | 74 +++++ man/scale_alpha_logical.Rd | 40 +++ tests/testthat/test-period_completeness.R | 312 +++++++++++++++++++ 9 files changed, 972 insertions(+) create mode 100644 R/period_completeness.R create mode 100644 man/filter_complete_periods.Rd create mode 100644 man/last_complete_period.Rd create mode 100644 man/period_bounds.Rd create mode 100644 man/period_is_complete.Rd create mode 100644 man/scale_alpha_logical.Rd create mode 100644 tests/testthat/test-period_completeness.R diff --git a/NAMESPACE b/NAMESPACE index 19ae27e..378c1aa 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -7,20 +7,27 @@ export(bizday_of_period) export(bizdays_between) export(breaks_quarters) export(detect_hidden_logicals) +export(filter_complete_periods) export(guess_col_fmts) export(is_bizday) export(is_ytd_comparable) export(label_quarters_short) +export(last_complete_period_end) +export(last_complete_period_start) export(local_calendar) export(most_recent_monday) export(most_recent_sunday) export(mutate_cagrs) export(normalize_logicals) export(passed_colnames) +export(period_end_date) +export(period_is_complete) +export(period_start_date) export(periodic_bizdays) export(plot_accounts_by_status) export(py_dates) export(rename_cols_for_display) +export(scale_alpha_logical) export(scale_x_integer) export(scale_y_integer) export(set_calendar) diff --git a/NEWS.md b/NEWS.md index 2133e29..2d42a08 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,25 @@ +# mcrutils (development version) + +## New +- Period-completeness toolkit for reporting on partial periods: + - `period_is_complete()` tests whether the period (week/month/quarter/year) + containing each date is complete as of a cutoff date. Completeness is + business-day aware via a QuantLib calendar (default `"WeekendsOnly"`); pass + a national calendar such as `"UnitedStates"` for holiday accuracy, or + `"Null"` for pure calendar-day semantics. + - `period_start_date()` and `period_end_date()` return the canonical first and + last calendar day of the period containing each date. + - `filter_complete_periods()` keeps only rows whose period is complete; + `as_of` defaults to the maximum of the date column. + - `last_complete_period_start()` and `last_complete_period_end()` return the + boundaries of the most recent complete period as of a cutoff. + - `scale_alpha_logical()`, a ggplot2 alpha scale for fading incomplete + periods (pairs with `period_is_complete()`). +- Completeness uses an inclusive convention: a period is complete once its end + date is reached (not strictly after it). For pure calendar-day semantics use + `calendar = "Null"` (the QuantLib id is `"Null"`, not `"NullCalendar"`). + `NA` dates propagate to `NA` (and are dropped by `filter_complete_periods()`). + # mcrutils 0.0.0.9013 ## Behavior changes diff --git a/R/period_completeness.R b/R/period_completeness.R new file mode 100644 index 0000000..4188295 --- /dev/null +++ b/R/period_completeness.R @@ -0,0 +1,352 @@ +#' Validate a period unit +#' +#' @param unit A period unit string. +#' @return The validated `unit`, or an error. +#' @keywords internal +#' @noRd +validate_period_unit <- function(unit) { + rlang::arg_match0( + unit, + values = c("day", "week", "month", "quarter", "year"), + arg_nm = "unit" + ) +} + +#' Business days remaining between a cutoff and a period end +#' +#' Vectorized count of business days in the half-open interval `(from, to]`, +#' clamped so [qlcal::businessDaysBetween()] never receives `from > to`, and +#' masked to `0L` whenever the cutoff has already reached or passed the end. +#' +#' @param from Cutoff date(s) (the reference position, e.g. `as_of`). +#' @param to Period end date(s). +#' @param calendar (character) A QuantLib calendar id. +#' @return An integer vector of business days remaining. +#' @keywords internal +#' @noRd +n_bizdays_remaining <- function(from, to, calendar) { + from <- as.Date(from) + to <- as.Date(to) + + n <- max(length(from), length(to)) + from <- rep_len(from, n) + to <- rep_len(to, n) + + # qlcal errors on NA (and out-of-range) dates, so substitute a safe in-range + # placeholder for NA endpoints and restore NA in the result afterwards. + na_mask <- is.na(from) | is.na(to) + placeholder <- as.Date("2000-01-01") + from[na_mask] <- placeholder + to[na_mask] <- placeholder + + # clamp so qlcal never sees from > to; the mask below fixes those elements + from_clamped <- pmin(from, to) + + remaining <- bizdays_between( + from = from_clamped, + to = to, + calendar = calendar, + include_first = FALSE, + include_last = TRUE + ) + + remaining <- as.integer(remaining) + remaining[from >= to] <- 0L + remaining[na_mask] <- NA_integer_ + remaining +} + +#' Start or end date of the period containing each date +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' `period_start_date()` and `period_end_date()` return the first and last +#' *calendar* day of the period containing each `date`. They are thin, coercing, +#' vectorized wrappers around [lubridate::floor_date()] and +#' [lubridate::ceiling_date()] that always return a [Date] (never a datetime) +#' and validate `unit`. +#' +#' The returned dates are always canonical calendar boundaries. Business-day +#' calendars are used only to *judge completeness* (see [period_is_complete()]), +#' never to compute a boundary date. +#' +#' @param date A vector of dates (Date or coercible with [lubridate::as_date()]). +#' @param unit Period size, one of `"day"`, `"week"`, `"month"`, `"quarter"`, +#' or `"year"`. +#' @param week_start Integer 1-7 giving the first day of the week (only used when +#' `unit = "week"`). Defaults to `getOption("lubridate.week.start", 7)`, so the +#' package defers to the session's lubridate week convention. +#' @return A [Date] vector the same length as `date`. +#' @seealso [period_is_complete()], [last_complete_period_end()] +#' @name period_bounds +#' @examples +#' period_start_date(as.Date("2025-05-14"), "month") # 2025-05-01 +#' period_end_date(as.Date("2025-05-14"), "month") # 2025-05-31 +#' period_end_date(as.Date("2025-05-14"), "quarter") # 2025-06-30 +NULL + +#' @rdname period_bounds +#' @export +period_start_date <- function(date, unit = "month", + week_start = getOption("lubridate.week.start", 7)) { + unit <- validate_period_unit(unit) + date <- lubridate::as_date(date) + as.Date(lubridate::floor_date(date, unit = unit, week_start = week_start)) +} + +#' @rdname period_bounds +#' @export +period_end_date <- function(date, unit = "month", + week_start = getOption("lubridate.week.start", 7)) { + unit <- validate_period_unit(unit) + date <- lubridate::as_date(date) + ceiling <- lubridate::ceiling_date( + date, + unit = unit, + week_start = week_start, + change_on_boundary = TRUE + ) + as.Date(ceiling) - 1L +} + +#' Is the period containing each date complete as of a cutoff? +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' For each date in `date`, tests whether the period containing it is complete +#' as of `as_of` (typically `max(order_date)` or a report end date). A period is +#' complete once no business days remain after `as_of` up to and including the +#' period end, evaluated in `calendar`. +#' +#' Choosing `calendar` selects the completeness policy: +#' \itemize{ +#' \item `"WeekendsOnly"` (default) treats Mon-Fri as working days with no +#' holidays -- a period is complete once only weekends remain. +#' \item A national calendar such as `"UnitedStates"` additionally treats +#' holidays as non-working, giving holiday-accurate completeness. +#' \item `"Null"` treats every calendar day as a working day, so completeness +#' reduces to "every calendar day of the period has elapsed" +#' (`period_end_date(date) <= as_of`). +#' } +#' See [qlcal::calendars] for valid ids. +#' +#' @param date A vector of dates (Date or coercible with [as.Date()]); each value +#' identifies the period to test. +#' @param unit Period size, one of `"day"`, `"week"`, `"month"`, `"quarter"`, +#' or `"year"`. +#' @param as_of Reference cutoff date (Date or coercible), length 1 or the length +#' of `date`. Defaults to [Sys.Date()]. +#' @param calendar (character) A QuantLib calendar id (see [qlcal::calendars]). +#' Defaults to `"WeekendsOnly"`. +#' @param week_start Integer 1-7 giving the first day of the week (only used when +#' `unit = "week"`). Defaults to `getOption("lubridate.week.start", 7)`. +#' @return A logical vector the same length as `date`. +#' @seealso [filter_complete_periods()], [last_complete_period_end()], +#' [scale_alpha_logical()], [period_end_date()] +#' @export +#' @examples +#' # As of 2025-05-30 (a Friday), April and May are complete under the +#' # weekends-only calendar -- May's only remaining day, the 31st, is a Saturday -- +#' # but June is not. Returns c(TRUE, TRUE, FALSE). +#' period_is_complete( +#' as.Date(c("2025-04-15", "2025-05-15", "2025-06-15")), +#' unit = "month", +#' as_of = as.Date("2025-05-30") +#' ) +#' +#' # Pure calendar-day semantics: May is not complete until 2025-05-31 elapses. +#' period_is_complete( +#' as.Date("2025-05-15"), +#' as_of = as.Date("2025-05-30"), +#' calendar = "Null" +#' ) +period_is_complete <- function(date, unit = "month", as_of = Sys.Date(), + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7)) { + unit <- validate_period_unit(unit) + as_of <- as.Date(as_of) + end <- period_end_date(date, unit = unit, week_start = week_start) + + if (!length(as_of) %in% c(1L, length(end))) { + cli::cli_abort(c( + "{.arg as_of} must be length 1 or the same length as {.arg date}.", + "x" = "{.arg as_of} has length {length(as_of)}; {.arg date} has length {length(end)}." + )) + } + + n_bizdays_remaining(from = as_of, to = end, calendar = calendar) == 0L +} + +#' Keep only rows whose period is complete as of a cutoff +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' Filters `data` to the rows whose period (per [period_is_complete()]) is +#' complete as of `as_of`. Useful for dropping the in-progress trailing period +#' from a summary or year-over-year comparison. +#' +#' @param data A data frame. +#' @param date_col <[`data-masked`][dplyr::dplyr_data_masking]> The date column +#' whose period completeness is tested. +#' @param unit Period size, one of `"day"`, `"week"`, `"month"`, `"quarter"`, +#' or `"year"`. +#' @param as_of Reference cutoff date, a single Date (or coercible). Defaults to +#' `NULL`, in which case it is set to the maximum of `date_col` so completeness +#' is judged against how current the data actually is. An explicit `as_of` +#' *earlier* than the maximum of `date_col` is an error, because it would drop +#' periods the data demonstrably completes; use [period_is_complete()] directly +#' for point-in-time completeness. +#' @param calendar (character) A QuantLib calendar id (see [qlcal::calendars]). +#' Defaults to `"WeekendsOnly"`. +#' @param week_start Integer 1-7 giving the first day of the week (only used when +#' `unit = "week"`). Defaults to `getOption("lubridate.week.start", 7)`. +#' @return `data` filtered to rows in complete periods. +#' @seealso [period_is_complete()] +#' @export +#' @examples +#' df <- data.frame(day = seq(as.Date("2025-04-01"), as.Date("2025-06-12"), by = "day")) +#' # as_of defaults to max(day) = 2025-06-12, so the partial June is dropped +#' nrow(filter_complete_periods(df, day, unit = "month")) +filter_complete_periods <- function(data, date_col, unit = "month", as_of = NULL, + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7)) { + unit <- validate_period_unit(unit) + + dates <- as.Date(dplyr::pull(data, {{ date_col }})) + # NA when there are no non-missing dates; rows then all read incomplete (NA) + data_max <- if (all(is.na(dates))) as.Date(NA) else max(dates, na.rm = TRUE) + + if (is.null(as_of)) { + as_of <- data_max + } else { + as_of <- as.Date(as_of) + if (length(as_of) != 1L) { + cli::cli_abort(c( + "{.arg as_of} must be a single date, or {.code NULL} to use the maximum of {.arg date_col}.", + "x" = "{.arg as_of} has length {length(as_of)}." + )) + } + if (!is.na(data_max) && as_of < data_max) { + cli::cli_abort(c( + "{.arg as_of} ({.val {as_of}}) is earlier than the maximum of {.arg date_col} ({.val {data_max}}).", + "x" = "This would drop periods that are demonstrably complete in the data.", + "i" = "Use {.fn period_is_complete} directly for point-in-time completeness." + )) + } + } + + dplyr::filter( + data, + period_is_complete( + {{ date_col }}, + unit = unit, + as_of = as_of, + calendar = calendar, + week_start = week_start + ) + ) +} + +#' Boundary of the most recent complete period as of a cutoff +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' `last_complete_period_end()` returns the (canonical calendar) end date of the +#' most recent period that is complete as of `as_of`; `last_complete_period_start()` +#' returns that period's start date. Completeness is judged with +#' [period_is_complete()], so `calendar` selects the policy exactly as it does +#' there. +#' +#' @param as_of Reference cutoff date(s) (Date or coercible). Defaults to +#' [Sys.Date()]. +#' @param unit Period size, one of `"day"`, `"week"`, `"month"`, `"quarter"`, +#' or `"year"`. +#' @param calendar (character) A QuantLib calendar id (see [qlcal::calendars]). +#' Defaults to `"WeekendsOnly"`. +#' @param week_start Integer 1-7 giving the first day of the week (only used when +#' `unit = "week"`). Defaults to `getOption("lubridate.week.start", 7)`. +#' @return A [Date] vector the same length as `as_of`. +#' @seealso [period_is_complete()], [period_end_date()] +#' @name last_complete_period +#' @examples +#' # 2025-05-30 falls in Q2 2025, which is still in progress, so the most recent +#' # complete quarter ends 2025-03-31. +#' last_complete_period_end(as.Date("2025-05-30"), unit = "quarter") +#' last_complete_period_start(as.Date("2025-05-30"), unit = "quarter") +NULL + +#' @rdname last_complete_period +#' @export +last_complete_period_end <- function(as_of = Sys.Date(), unit = "month", + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7)) { + unit <- validate_period_unit(unit) + as_of <- as.Date(as_of) + + end_current <- period_end_date(as_of, unit = unit, week_start = week_start) + # canonical end of the immediately prior period + end_prior <- period_start_date(as_of, unit = unit, week_start = week_start) - 1L + + complete_now <- period_is_complete( + as_of, + unit = unit, + as_of = as_of, + calendar = calendar, + week_start = week_start + ) + + # dplyr::if_else preserves the Date class (base ifelse would strip it) + dplyr::if_else(complete_now, end_current, end_prior) +} + +#' @rdname last_complete_period +#' @export +last_complete_period_start <- function(as_of = Sys.Date(), unit = "month", + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7)) { + end <- last_complete_period_end( + as_of, + unit = unit, + calendar = calendar, + week_start = week_start + ) + period_start_date(end, unit = unit, week_start = week_start) +} + +#' A ggplot2 alpha scale for complete versus incomplete periods +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' A manual alpha scale mapping `FALSE`/`TRUE` to two opacity levels, with the +#' guide hidden. Pairs with [period_is_complete()] to fade the in-progress +#' period in bar or column charts, e.g. +#' `ggplot2::aes(alpha = period_is_complete(date, as_of = cutoff))`. +#' +#' @param false_alpha Alpha applied to `FALSE` (incomplete) values. Defaults to +#' 0.5. +#' @param true_alpha Alpha applied to `TRUE` (complete) values. Defaults to 1. +#' @return A ggplot2 scale object. +#' @seealso [period_is_complete()] +#' @export +#' @examples +#' library(ggplot2) +#' df <- data.frame( +#' month = as.Date(c("2025-04-01", "2025-05-01", "2025-06-01")), +#' value = c(10, 12, 4) +#' ) +#' ggplot(df, aes(month, value, +#' alpha = period_is_complete(month, as_of = as.Date("2025-06-15")) +#' )) + +#' geom_col() + +#' scale_alpha_logical() +scale_alpha_logical <- function(false_alpha = 0.5, true_alpha = 1) { + ggplot2::scale_alpha_manual( + values = c("FALSE" = false_alpha, "TRUE" = true_alpha), + guide = "none" + ) +} diff --git a/man/filter_complete_periods.Rd b/man/filter_complete_periods.Rd new file mode 100644 index 0000000..c90178e --- /dev/null +++ b/man/filter_complete_periods.Rd @@ -0,0 +1,55 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/period_completeness.R +\name{filter_complete_periods} +\alias{filter_complete_periods} +\title{Keep only rows whose period is complete as of a cutoff} +\usage{ +filter_complete_periods( + data, + date_col, + unit = "month", + as_of = NULL, + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7) +) +} +\arguments{ +\item{data}{A data frame.} + +\item{date_col}{<\code{\link[dplyr:dplyr_data_masking]{data-masked}}> The date column +whose period completeness is tested.} + +\item{unit}{Period size, one of \code{"day"}, \code{"week"}, \code{"month"}, \code{"quarter"}, +or \code{"year"}.} + +\item{as_of}{Reference cutoff date, a single Date (or coercible). Defaults to +\code{NULL}, in which case it is set to the maximum of \code{date_col} so completeness +is judged against how current the data actually is. An explicit \code{as_of} +\emph{earlier} than the maximum of \code{date_col} is an error, because it would drop +periods the data demonstrably completes; use \code{\link[=period_is_complete]{period_is_complete()}} directly +for point-in-time completeness.} + +\item{calendar}{(character) A QuantLib calendar id (see \link[qlcal:calendars]{qlcal::calendars}). +Defaults to \code{"WeekendsOnly"}.} + +\item{week_start}{Integer 1-7 giving the first day of the week (only used when +\code{unit = "week"}). Defaults to \code{getOption("lubridate.week.start", 7)}.} +} +\value{ +\code{data} filtered to rows in complete periods. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +Filters \code{data} to the rows whose period (per \code{\link[=period_is_complete]{period_is_complete()}}) is +complete as of \code{as_of}. Useful for dropping the in-progress trailing period +from a summary or year-over-year comparison. +} +\examples{ +df <- data.frame(day = seq(as.Date("2025-04-01"), as.Date("2025-06-12"), by = "day")) +# as_of defaults to max(day) = 2025-06-12, so the partial June is dropped +nrow(filter_complete_periods(df, day, unit = "month")) +} +\seealso{ +\code{\link[=period_is_complete]{period_is_complete()}} +} diff --git a/man/last_complete_period.Rd b/man/last_complete_period.Rd new file mode 100644 index 0000000..079c9e2 --- /dev/null +++ b/man/last_complete_period.Rd @@ -0,0 +1,56 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/period_completeness.R +\name{last_complete_period} +\alias{last_complete_period} +\alias{last_complete_period_end} +\alias{last_complete_period_start} +\title{Boundary of the most recent complete period as of a cutoff} +\usage{ +last_complete_period_end( + as_of = Sys.Date(), + unit = "month", + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7) +) + +last_complete_period_start( + as_of = Sys.Date(), + unit = "month", + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7) +) +} +\arguments{ +\item{as_of}{Reference cutoff date(s) (Date or coercible). Defaults to +\code{\link[=Sys.Date]{Sys.Date()}}.} + +\item{unit}{Period size, one of \code{"day"}, \code{"week"}, \code{"month"}, \code{"quarter"}, +or \code{"year"}.} + +\item{calendar}{(character) A QuantLib calendar id (see \link[qlcal:calendars]{qlcal::calendars}). +Defaults to \code{"WeekendsOnly"}.} + +\item{week_start}{Integer 1-7 giving the first day of the week (only used when +\code{unit = "week"}). Defaults to \code{getOption("lubridate.week.start", 7)}.} +} +\value{ +A \link[lubridate:date_utils]{lubridate::Date} vector the same length as \code{as_of}. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +\code{last_complete_period_end()} returns the (canonical calendar) end date of the +most recent period that is complete as of \code{as_of}; \code{last_complete_period_start()} +returns that period's start date. Completeness is judged with +\code{\link[=period_is_complete]{period_is_complete()}}, so \code{calendar} selects the policy exactly as it does +there. +} +\examples{ +# 2025-05-30 falls in Q2 2025, which is still in progress, so the most recent +# complete quarter ends 2025-03-31. +last_complete_period_end(as.Date("2025-05-30"), unit = "quarter") +last_complete_period_start(as.Date("2025-05-30"), unit = "quarter") +} +\seealso{ +\code{\link[=period_is_complete]{period_is_complete()}}, \code{\link[=period_end_date]{period_end_date()}} +} diff --git a/man/period_bounds.Rd b/man/period_bounds.Rd new file mode 100644 index 0000000..76f8955 --- /dev/null +++ b/man/period_bounds.Rd @@ -0,0 +1,54 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/period_completeness.R +\name{period_bounds} +\alias{period_bounds} +\alias{period_start_date} +\alias{period_end_date} +\title{Start or end date of the period containing each date} +\usage{ +period_start_date( + date, + unit = "month", + week_start = getOption("lubridate.week.start", 7) +) + +period_end_date( + date, + unit = "month", + week_start = getOption("lubridate.week.start", 7) +) +} +\arguments{ +\item{date}{A vector of dates (Date or coercible with \code{\link[lubridate:as_date]{lubridate::as_date()}}).} + +\item{unit}{Period size, one of \code{"day"}, \code{"week"}, \code{"month"}, \code{"quarter"}, +or \code{"year"}.} + +\item{week_start}{Integer 1-7 giving the first day of the week (only used when +\code{unit = "week"}). Defaults to \code{getOption("lubridate.week.start", 7)}, so the +package defers to the session's lubridate week convention.} +} +\value{ +A \link[lubridate:date_utils]{lubridate::Date} vector the same length as \code{date}. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +\code{period_start_date()} and \code{period_end_date()} return the first and last +\emph{calendar} day of the period containing each \code{date}. They are thin, coercing, +vectorized wrappers around \code{\link[lubridate:round_date]{lubridate::floor_date()}} and +\code{\link[lubridate:round_date]{lubridate::ceiling_date()}} that always return a \link[lubridate:date_utils]{lubridate::Date} (never a datetime) +and validate \code{unit}. + +The returned dates are always canonical calendar boundaries. Business-day +calendars are used only to \emph{judge completeness} (see \code{\link[=period_is_complete]{period_is_complete()}}), +never to compute a boundary date. +} +\examples{ +period_start_date(as.Date("2025-05-14"), "month") # 2025-05-01 +period_end_date(as.Date("2025-05-14"), "month") # 2025-05-31 +period_end_date(as.Date("2025-05-14"), "quarter") # 2025-06-30 +} +\seealso{ +\code{\link[=period_is_complete]{period_is_complete()}}, \code{\link[=last_complete_period_end]{last_complete_period_end()}} +} diff --git a/man/period_is_complete.Rd b/man/period_is_complete.Rd new file mode 100644 index 0000000..008d291 --- /dev/null +++ b/man/period_is_complete.Rd @@ -0,0 +1,74 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/period_completeness.R +\name{period_is_complete} +\alias{period_is_complete} +\title{Is the period containing each date complete as of a cutoff?} +\usage{ +period_is_complete( + date, + unit = "month", + as_of = Sys.Date(), + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7) +) +} +\arguments{ +\item{date}{A vector of dates (Date or coercible with \code{\link[=as.Date]{as.Date()}}); each value +identifies the period to test.} + +\item{unit}{Period size, one of \code{"day"}, \code{"week"}, \code{"month"}, \code{"quarter"}, +or \code{"year"}.} + +\item{as_of}{Reference cutoff date (Date or coercible), length 1 or the length +of \code{date}. Defaults to \code{\link[=Sys.Date]{Sys.Date()}}.} + +\item{calendar}{(character) A QuantLib calendar id (see \link[qlcal:calendars]{qlcal::calendars}). +Defaults to \code{"WeekendsOnly"}.} + +\item{week_start}{Integer 1-7 giving the first day of the week (only used when +\code{unit = "week"}). Defaults to \code{getOption("lubridate.week.start", 7)}.} +} +\value{ +A logical vector the same length as \code{date}. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +For each date in \code{date}, tests whether the period containing it is complete +as of \code{as_of} (typically \code{max(order_date)} or a report end date). A period is +complete once no business days remain after \code{as_of} up to and including the +period end, evaluated in \code{calendar}. + +Choosing \code{calendar} selects the completeness policy: +\itemize{ +\item \code{"WeekendsOnly"} (default) treats Mon-Fri as working days with no +holidays -- a period is complete once only weekends remain. +\item A national calendar such as \code{"UnitedStates"} additionally treats +holidays as non-working, giving holiday-accurate completeness. +\item \code{"Null"} treats every calendar day as a working day, so completeness +reduces to "every calendar day of the period has elapsed" +(\code{period_end_date(date) <= as_of}). +} +See \link[qlcal:calendars]{qlcal::calendars} for valid ids. +} +\examples{ +# As of 2025-05-30 (a Friday), April and May are complete under the +# weekends-only calendar -- May's only remaining day, the 31st, is a Saturday -- +# but June is not. Returns c(TRUE, TRUE, FALSE). +period_is_complete( + as.Date(c("2025-04-15", "2025-05-15", "2025-06-15")), + unit = "month", + as_of = as.Date("2025-05-30") +) + +# Pure calendar-day semantics: May is not complete until 2025-05-31 elapses. +period_is_complete( + as.Date("2025-05-15"), + as_of = as.Date("2025-05-30"), + calendar = "Null" +) +} +\seealso{ +\code{\link[=filter_complete_periods]{filter_complete_periods()}}, \code{\link[=last_complete_period_end]{last_complete_period_end()}}, +\code{\link[=scale_alpha_logical]{scale_alpha_logical()}}, \code{\link[=period_end_date]{period_end_date()}} +} diff --git a/man/scale_alpha_logical.Rd b/man/scale_alpha_logical.Rd new file mode 100644 index 0000000..fde99c4 --- /dev/null +++ b/man/scale_alpha_logical.Rd @@ -0,0 +1,40 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/period_completeness.R +\name{scale_alpha_logical} +\alias{scale_alpha_logical} +\title{A ggplot2 alpha scale for complete versus incomplete periods} +\usage{ +scale_alpha_logical(false_alpha = 0.5, true_alpha = 1) +} +\arguments{ +\item{false_alpha}{Alpha applied to \code{FALSE} (incomplete) values. Defaults to +0.5.} + +\item{true_alpha}{Alpha applied to \code{TRUE} (complete) values. Defaults to 1.} +} +\value{ +A ggplot2 scale object. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +A manual alpha scale mapping \code{FALSE}/\code{TRUE} to two opacity levels, with the +guide hidden. Pairs with \code{\link[=period_is_complete]{period_is_complete()}} to fade the in-progress +period in bar or column charts, e.g. +\code{ggplot2::aes(alpha = period_is_complete(date, as_of = cutoff))}. +} +\examples{ +library(ggplot2) +df <- data.frame( + month = as.Date(c("2025-04-01", "2025-05-01", "2025-06-01")), + value = c(10, 12, 4) +) +ggplot(df, aes(month, value, + alpha = period_is_complete(month, as_of = as.Date("2025-06-15")) +)) + + geom_col() + + scale_alpha_logical() +} +\seealso{ +\code{\link[=period_is_complete]{period_is_complete()}} +} diff --git a/tests/testthat/test-period_completeness.R b/tests/testthat/test-period_completeness.R new file mode 100644 index 0000000..3684fc0 --- /dev/null +++ b/tests/testthat/test-period_completeness.R @@ -0,0 +1,312 @@ +# period_start_date() / period_end_date() ------------------------------------ + +test_that("period boundaries return canonical calendar dates", { + expect_equal(period_start_date(as.Date("2025-05-14"), "month"), as.Date("2025-05-01")) + expect_equal(period_end_date(as.Date("2025-05-14"), "month"), as.Date("2025-05-31")) + expect_equal(period_start_date(as.Date("2025-05-14"), "quarter"), as.Date("2025-04-01")) + expect_equal(period_end_date(as.Date("2025-05-14"), "quarter"), as.Date("2025-06-30")) + expect_equal(period_start_date(as.Date("2025-05-14"), "year"), as.Date("2025-01-01")) + expect_equal(period_end_date(as.Date("2025-05-14"), "year"), as.Date("2025-12-31")) +}) + +test_that("period_end_date() is correct on a period-boundary date", { + # 2025-01-01 is a month boundary; its period end is still 2025-01-31 + expect_equal(period_end_date(as.Date("2025-01-01"), "month"), as.Date("2025-01-31")) +}) + +test_that("period boundaries handle leap February and coerce strings", { + expect_equal(period_end_date("2024-02-10", "month"), as.Date("2024-02-29")) + expect_equal(period_end_date("2025-02-10", "month"), as.Date("2025-02-28")) +}) + +test_that("period boundaries are vectorized and return Date", { + out <- period_end_date(as.Date(c("2025-01-15", "2025-02-15")), "month") + expect_s3_class(out, "Date") + expect_equal(out, as.Date(c("2025-01-31", "2025-02-28"))) +}) + +test_that("week boundaries respect week_start", { + # 2025-07-03 is a Thursday + expect_equal( + period_start_date(as.Date("2025-07-03"), "week", week_start = 1), # Monday + as.Date("2025-06-30") + ) + expect_equal( + period_start_date(as.Date("2025-07-03"), "week", week_start = 7), # Sunday + as.Date("2025-06-29") + ) +}) + +# period_is_complete() ------------------------------------------------------- + +test_that("period_is_complete() is TRUE when as_of is on the period end", { + expect_true(period_is_complete(as.Date("2025-05-15"), "month", as.Date("2025-05-31"))) +}) + +test_that("a month ending on a weekend is complete after its last business day", { + # May 2025 ends Saturday the 31st; the last business day is Friday the 30th + expect_true(period_is_complete(as.Date("2025-05-15"), "month", as.Date("2025-05-30"))) + expect_false(period_is_complete(as.Date("2025-05-15"), "month", as.Date("2025-05-29"))) +}) + +test_that("calendar = 'Null' reduces to pure calendar-day completeness", { + # May is not complete until every calendar day has elapsed + expect_false( + period_is_complete(as.Date("2025-05-15"), "month", as.Date("2025-05-30"), calendar = "Null") + ) + expect_true( + period_is_complete(as.Date("2025-05-15"), "month", as.Date("2025-05-31"), calendar = "Null") + ) + + # equivalence to period_end_date(date) <= as_of across a date sweep + set.seed(1) + dates <- as.Date("2025-01-01") + sample(0:400, 60) + as_of <- as.Date("2025-06-10") + expect_equal( + period_is_complete(dates, "month", as_of, calendar = "Null"), + period_end_date(dates, "month") <= as_of + ) +}) + +test_that("period_is_complete() is vectorized correctly over a span of periods", { + # Regression guard: as_of (2025-05-30) is the last business day of its own + # month, yet a future month must still read incomplete. A naive implementation + # that tests as_of's own period instead of each date's period would wrongly + # mark future periods complete here. + expect_equal( + period_is_complete( + as.Date(c("2025-04-15", "2025-05-15", "2025-06-15")), + "month", + as_of = as.Date("2025-05-30") + ), + c(TRUE, TRUE, FALSE) + ) +}) + +test_that("a holiday calendar completes a period earlier than weekends-only", { + # Week of 2025-06-30..2025-07-06 (Monday start); Fri 2025-07-04 is a US holiday. + # As of Thursday 2025-07-03 only Fri (holiday) + weekend remain. + thu <- as.Date("2025-07-03") + expect_true( + period_is_complete(thu, "week", thu, calendar = "UnitedStates", week_start = 1) + ) + expect_false( + period_is_complete(thu, "week", thu, calendar = "WeekendsOnly", week_start = 1) + ) +}) + +test_that("period_is_complete() errors on a length-mismatched as_of", { + expect_error( + period_is_complete( + as.Date(c("2025-01-15", "2025-02-15", "2025-03-15")), + "month", + as_of = as.Date(c("2025-01-01", "2025-02-01")) + ), + "length 1 or the same length" + ) +}) + +test_that("period_is_complete() errors on an invalid unit", { + expect_error( + period_is_complete(as.Date("2025-05-15"), unit = "fortnight"), + class = "rlang_error" + ) +}) + +# filter_complete_periods() -------------------------------------------------- + +test_that("filter_complete_periods() drops a partial trailing month (common case)", { + # The common case: a daily series that runs only partway into the latest + # month. April and May are fully elapsed, but June is populated only through + # the 12th, so the whole partial month is judged incomplete and dropped. + # as_of defaults to max(day) = 2025-06-12. + df <- data.frame(day = seq(as.Date("2025-04-01"), as.Date("2025-06-12"), by = "day")) + out <- filter_complete_periods(df, day, unit = "month") + + expect_equal(nrow(out), 61L) # all of April (30) + May (31) + expect_equal(min(out$day), as.Date("2025-04-01")) + expect_equal(max(out$day), as.Date("2025-05-31")) + expect_true(all(out$day < as.Date("2025-06-01"))) # no partial-June rows survive +}) + +test_that("filter_complete_periods() honours an as_of later than the data max", { + # Assert the data is current through 2025-06-30 even though its last row is the + # 12th; the (data-partial) June then counts as a complete period and is kept. + df <- data.frame(day = seq(as.Date("2025-04-01"), as.Date("2025-06-12"), by = "day")) + out <- filter_complete_periods(df, day, unit = "month", as_of = as.Date("2025-06-30")) + + expect_equal(nrow(out), 73L) # April (30) + May (31) + partial June (12) + expect_true(any(out$day >= as.Date("2025-06-01"))) +}) + +test_that("filter_complete_periods() completes a trailing period when as_of is in the next period", { + # Data collection for June is done (last row 2026-06-28, a Sunday) and we are + # now in July. With as_of in the next period, June is a complete period and is + # kept in full -- even though its final Mon/Tue (29th, 30th) carry no rows. + df <- data.frame(day = seq(as.Date("2026-05-01"), as.Date("2026-06-28"), by = "day")) + + kept <- filter_complete_periods(df, day, unit = "month", as_of = as.Date("2026-07-02")) + expect_equal(nrow(kept), 59L) # all of May (31) + June 1-28 (28) + expect_equal(max(kept$day), as.Date("2026-06-28")) + + # Contrast: with the default as_of = max(day) = 2026-06-28, June still has two + # business days remaining (Mon 29th, Tue 30th), so the June rows are dropped. + default_kept <- filter_complete_periods(df, day, unit = "month") + expect_equal(nrow(default_kept), 31L) # May only + expect_true(all(default_kept$day < as.Date("2026-06-01"))) +}) + +test_that("filter_complete_periods() rejects an as_of earlier than the data max", { + # An as_of before the data's max would drop periods the data demonstrably + # completes, so it is an error. + df <- data.frame(day = seq(as.Date("2025-04-01"), as.Date("2025-06-12"), by = "day")) + expect_error( + filter_complete_periods(df, day, unit = "month", as_of = as.Date("2025-05-15")), + "earlier than the maximum" + ) +}) + +# last_complete_period_end() / last_complete_period_start() ------------------- + +test_that("last_complete_period_end() steps back over an in-progress period", { + # 2025-05-30 is in Q2 2025, still in progress -> last complete quarter is Q1 + end <- last_complete_period_end(as.Date("2025-05-30"), "quarter") + expect_s3_class(end, "Date") + expect_equal(end, as.Date("2025-03-31")) + expect_equal( + last_complete_period_start(as.Date("2025-05-30"), "quarter"), + as.Date("2025-01-01") + ) +}) + +test_that("last_complete_period_end() returns the current period end when it is complete", { + # As of 2025-03-31 (a Monday, the last day of Q1), Q1 is complete + expect_equal( + last_complete_period_end(as.Date("2025-03-31"), "quarter"), + as.Date("2025-03-31") + ) +}) + +test_that("last_complete_period_end() is vectorized over as_of", { + out <- last_complete_period_end( + as.Date(c("2025-05-30", "2025-08-15")), + "quarter" + ) + expect_equal(out, as.Date(c("2025-03-31", "2025-06-30"))) +}) + +# scale_alpha_logical() ------------------------------------------------------ + +test_that("scale_alpha_logical() returns a ggplot2 scale", { + expect_s3_class(scale_alpha_logical(), "Scale") +}) + +test_that("scale_alpha_logical() maps FALSE/TRUE to the supplied alpha levels", { + p <- ggplot2::ggplot( + data.frame(x = 1:2, done = c(FALSE, TRUE)), + ggplot2::aes(x, x, alpha = done) + ) + + ggplot2::geom_point() + + scale_alpha_logical(false_alpha = 0.2, true_alpha = 0.9) + built <- ggplot2::ggplot_build(p)$data[[1]] + expect_setequal(built$alpha, c(0.2, 0.9)) +}) + +# NA handling ---------------------------------------------------------------- + +test_that("period_is_complete() returns NA for NA dates", { + expect_equal( + period_is_complete( + as.Date(c("2025-04-15", NA, "2025-06-15")), + "month", + as_of = as.Date("2025-06-30") + ), + c(TRUE, NA, TRUE) + ) +}) + +test_that("filter_complete_periods() drops NA-date rows without error", { + # default as_of = max(day, na.rm) = 2025-06-15: April complete, NA dropped, + # June still in progress + df <- data.frame(day = as.Date(c("2025-04-15", NA, "2025-06-15"))) + out <- filter_complete_periods(df, day, unit = "month") + expect_equal(out$day, as.Date("2025-04-15")) +}) + +test_that("filter_complete_periods() returns no rows for an all-NA date column", { + df <- data.frame(day = as.Date(c(NA, NA))) + expect_no_warning(out <- filter_complete_periods(df, day, unit = "month")) + expect_equal(nrow(out), 0L) +}) + +# additional argument / unit coverage ---------------------------------------- + +test_that("period_is_complete() accepts a vector as_of matching date", { + expect_equal( + period_is_complete( + as.Date(c("2025-01-15", "2025-02-15", "2025-06-15")), + "month", + as_of = as.Date(c("2025-01-31", "2025-02-15", "2025-06-30")) + ), + c(TRUE, FALSE, TRUE) + ) +}) + +test_that("period_end_date() respects week_start", { + # 2025-07-03 is a Thursday + expect_equal(period_end_date("2025-07-03", "week", week_start = 1), as.Date("2025-07-06")) # Sun + expect_equal(period_end_date("2025-07-03", "week", week_start = 7), as.Date("2025-07-05")) # Sat +}) + +test_that("period_is_complete() handles day and year units", { + # a weekday is complete once it has passed; the day of as_of counts as complete + expect_true(period_is_complete(as.Date("2025-06-16"), "day", as.Date("2025-06-16"))) + expect_false(period_is_complete(as.Date("2025-06-16"), "day", as.Date("2025-06-15"))) + # business-day semantics: a Sunday reads complete as of the preceding Saturday + expect_true(period_is_complete(as.Date("2025-06-15"), "day", as.Date("2025-06-14"))) + + # year: 2024-12-31 is a business day (Tuesday), so the year is not complete + # until it elapses + expect_false(period_is_complete(as.Date("2024-06-15"), "year", as.Date("2024-12-30"))) + expect_true(period_is_complete(as.Date("2024-06-15"), "year", as.Date("2024-12-31"))) +}) + +test_that("period boundary functions validate the unit argument", { + expect_error(period_start_date(as.Date("2025-05-15"), "fortnight"), class = "rlang_error") + expect_error(period_end_date(as.Date("2025-05-15"), "fortnight"), class = "rlang_error") +}) + +test_that("filter_complete_periods() errors on a length > 1 as_of", { + df <- data.frame(day = as.Date(c("2025-04-15", "2025-05-15"))) + expect_error( + filter_complete_periods( + df, day, + unit = "month", + as_of = as.Date(c("2025-05-31", "2025-06-30")) + ), + "single date" + ) +}) + +test_that("last_complete_period_*() work for the default month unit", { + # 2025-05-15 is mid-May, so the last complete month is April + expect_equal(last_complete_period_end(as.Date("2025-05-15")), as.Date("2025-04-30")) + expect_equal(last_complete_period_start(as.Date("2025-05-15")), as.Date("2025-04-01")) +}) + +# internal helper ------------------------------------------------------------ + +test_that("n_bizdays_remaining() recycles both directions and propagates NA", { + expect_equal( + n_bizdays_remaining(as.Date("2025-06-02"), as.Date(c("2025-06-02", "2025-06-03")), "Null"), + c(0L, 1L) + ) + expect_equal( + n_bizdays_remaining(as.Date(c("2025-06-02", "2025-06-03")), as.Date("2025-06-03"), "Null"), + c(1L, 0L) + ) + expect_equal( + n_bizdays_remaining(as.Date(c("2025-06-02", NA)), as.Date("2025-06-10"), "Null"), + c(8L, NA_integer_) + ) +}) From a767430539e092ad0e90e82a13ab9e3f1777c45e Mon Sep 17 00:00:00 2001 From: Michael Caselli Date: Tue, 14 Jul 2026 15:14:26 -0400 Subject: [PATCH 2/8] Drop "day" as a valid period unit Restrict the period-completeness family to week/month/quarter/year. "day" had no real use case here: the boundary functions are no-ops for it (start and end both equal the input date), and business-day completeness produces misleading results for single days (a Sunday reads complete as of the preceding Saturday). "Is a day complete" is just date <= as_of and needs none of this machinery. Invalid units, including "day", now error via validate_period_unit(). Co-Authored-By: Claude Opus 4.8 --- R/period_completeness.R | 10 +++++----- man/filter_complete_periods.Rd | 2 +- man/last_complete_period.Rd | 2 +- man/period_bounds.Rd | 2 +- man/period_is_complete.Rd | 2 +- tests/testthat/test-period_completeness.R | 14 ++++++-------- 6 files changed, 15 insertions(+), 17 deletions(-) diff --git a/R/period_completeness.R b/R/period_completeness.R index 4188295..a153069 100644 --- a/R/period_completeness.R +++ b/R/period_completeness.R @@ -7,7 +7,7 @@ validate_period_unit <- function(unit) { rlang::arg_match0( unit, - values = c("day", "week", "month", "quarter", "year"), + values = c("week", "month", "quarter", "year"), arg_nm = "unit" ) } @@ -72,7 +72,7 @@ n_bizdays_remaining <- function(from, to, calendar) { #' never to compute a boundary date. #' #' @param date A vector of dates (Date or coercible with [lubridate::as_date()]). -#' @param unit Period size, one of `"day"`, `"week"`, `"month"`, `"quarter"`, +#' @param unit Period size, one of `"week"`, `"month"`, `"quarter"`, #' or `"year"`. #' @param week_start Integer 1-7 giving the first day of the week (only used when #' `unit = "week"`). Defaults to `getOption("lubridate.week.start", 7)`, so the @@ -134,7 +134,7 @@ period_end_date <- function(date, unit = "month", #' #' @param date A vector of dates (Date or coercible with [as.Date()]); each value #' identifies the period to test. -#' @param unit Period size, one of `"day"`, `"week"`, `"month"`, `"quarter"`, +#' @param unit Period size, one of `"week"`, `"month"`, `"quarter"`, #' or `"year"`. #' @param as_of Reference cutoff date (Date or coercible), length 1 or the length #' of `date`. Defaults to [Sys.Date()]. @@ -191,7 +191,7 @@ period_is_complete <- function(date, unit = "month", as_of = Sys.Date(), #' @param data A data frame. #' @param date_col <[`data-masked`][dplyr::dplyr_data_masking]> The date column #' whose period completeness is tested. -#' @param unit Period size, one of `"day"`, `"week"`, `"month"`, `"quarter"`, +#' @param unit Period size, one of `"week"`, `"month"`, `"quarter"`, #' or `"year"`. #' @param as_of Reference cutoff date, a single Date (or coercible). Defaults to #' `NULL`, in which case it is set to the maximum of `date_col` so completeness @@ -263,7 +263,7 @@ filter_complete_periods <- function(data, date_col, unit = "month", as_of = NULL #' #' @param as_of Reference cutoff date(s) (Date or coercible). Defaults to #' [Sys.Date()]. -#' @param unit Period size, one of `"day"`, `"week"`, `"month"`, `"quarter"`, +#' @param unit Period size, one of `"week"`, `"month"`, `"quarter"`, #' or `"year"`. #' @param calendar (character) A QuantLib calendar id (see [qlcal::calendars]). #' Defaults to `"WeekendsOnly"`. diff --git a/man/filter_complete_periods.Rd b/man/filter_complete_periods.Rd index c90178e..6a00192 100644 --- a/man/filter_complete_periods.Rd +++ b/man/filter_complete_periods.Rd @@ -19,7 +19,7 @@ filter_complete_periods( \item{date_col}{<\code{\link[dplyr:dplyr_data_masking]{data-masked}}> The date column whose period completeness is tested.} -\item{unit}{Period size, one of \code{"day"}, \code{"week"}, \code{"month"}, \code{"quarter"}, +\item{unit}{Period size, one of \code{"week"}, \code{"month"}, \code{"quarter"}, or \code{"year"}.} \item{as_of}{Reference cutoff date, a single Date (or coercible). Defaults to diff --git a/man/last_complete_period.Rd b/man/last_complete_period.Rd index 079c9e2..a583e7a 100644 --- a/man/last_complete_period.Rd +++ b/man/last_complete_period.Rd @@ -24,7 +24,7 @@ last_complete_period_start( \item{as_of}{Reference cutoff date(s) (Date or coercible). Defaults to \code{\link[=Sys.Date]{Sys.Date()}}.} -\item{unit}{Period size, one of \code{"day"}, \code{"week"}, \code{"month"}, \code{"quarter"}, +\item{unit}{Period size, one of \code{"week"}, \code{"month"}, \code{"quarter"}, or \code{"year"}.} \item{calendar}{(character) A QuantLib calendar id (see \link[qlcal:calendars]{qlcal::calendars}). diff --git a/man/period_bounds.Rd b/man/period_bounds.Rd index 76f8955..c674fe4 100644 --- a/man/period_bounds.Rd +++ b/man/period_bounds.Rd @@ -21,7 +21,7 @@ period_end_date( \arguments{ \item{date}{A vector of dates (Date or coercible with \code{\link[lubridate:as_date]{lubridate::as_date()}}).} -\item{unit}{Period size, one of \code{"day"}, \code{"week"}, \code{"month"}, \code{"quarter"}, +\item{unit}{Period size, one of \code{"week"}, \code{"month"}, \code{"quarter"}, or \code{"year"}.} \item{week_start}{Integer 1-7 giving the first day of the week (only used when diff --git a/man/period_is_complete.Rd b/man/period_is_complete.Rd index 008d291..a64d99d 100644 --- a/man/period_is_complete.Rd +++ b/man/period_is_complete.Rd @@ -16,7 +16,7 @@ period_is_complete( \item{date}{A vector of dates (Date or coercible with \code{\link[=as.Date]{as.Date()}}); each value identifies the period to test.} -\item{unit}{Period size, one of \code{"day"}, \code{"week"}, \code{"month"}, \code{"quarter"}, +\item{unit}{Period size, one of \code{"week"}, \code{"month"}, \code{"quarter"}, or \code{"year"}.} \item{as_of}{Reference cutoff date (Date or coercible), length 1 or the length diff --git a/tests/testthat/test-period_completeness.R b/tests/testthat/test-period_completeness.R index 3684fc0..c692f23 100644 --- a/tests/testthat/test-period_completeness.R +++ b/tests/testthat/test-period_completeness.R @@ -258,20 +258,18 @@ test_that("period_end_date() respects week_start", { expect_equal(period_end_date("2025-07-03", "week", week_start = 7), as.Date("2025-07-05")) # Sat }) -test_that("period_is_complete() handles day and year units", { - # a weekday is complete once it has passed; the day of as_of counts as complete - expect_true(period_is_complete(as.Date("2025-06-16"), "day", as.Date("2025-06-16"))) - expect_false(period_is_complete(as.Date("2025-06-16"), "day", as.Date("2025-06-15"))) - # business-day semantics: a Sunday reads complete as of the preceding Saturday - expect_true(period_is_complete(as.Date("2025-06-15"), "day", as.Date("2025-06-14"))) - +test_that("period_is_complete() handles the year unit", { # year: 2024-12-31 is a business day (Tuesday), so the year is not complete # until it elapses expect_false(period_is_complete(as.Date("2024-06-15"), "year", as.Date("2024-12-30"))) expect_true(period_is_complete(as.Date("2024-06-15"), "year", as.Date("2024-12-31"))) }) -test_that("period boundary functions validate the unit argument", { +test_that("the period family rejects units outside week/month/quarter/year", { + # "day" is intentionally not a valid period unit for completeness reasoning + expect_error(period_start_date(as.Date("2025-05-15"), "day"), class = "rlang_error") + expect_error(period_end_date(as.Date("2025-05-15"), "day"), class = "rlang_error") + expect_error(period_is_complete(as.Date("2025-05-15"), unit = "day"), class = "rlang_error") expect_error(period_start_date(as.Date("2025-05-15"), "fortnight"), class = "rlang_error") expect_error(period_end_date(as.Date("2025-05-15"), "fortnight"), class = "rlang_error") }) From 9c93a97c53ea5e5ec18ac90afd8b0efc962d7b3e Mon Sep 17 00:00:00 2001 From: Michael Caselli Date: Tue, 14 Jul 2026 15:20:49 -0400 Subject: [PATCH 3/8] Document period-completeness in the vignette Add a "Period completeness" section covering period_start_date()/ period_end_date(), period_is_complete(), the scale_alpha_logical() alpha-fade for in-progress periods, filter_complete_periods(), and last_complete_period_end()/start(), worked through example_sales (whose final month is partial). Bridges from the existing dashed-final-period behavior of plot_accounts_by_status(). Scale back the business-day-of-period burn-up section: replace the by-market faceted example (a near-duplicate of the global one) with a one-line pointer, to keep the vignette focused. Co-Authored-By: Claude Opus 4.8 --- vignettes/mcrutils.Rmd | 98 +++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 26 deletions(-) diff --git a/vignettes/mcrutils.Rmd b/vignettes/mcrutils.Rmd index 0d44758..adbbaa3 100644 --- a/vignettes/mcrutils.Rmd +++ b/vignettes/mcrutils.Rmd @@ -178,6 +178,76 @@ example_sales |> ``` +## Period completeness + +The dashed final period above is one instance of a recurring problem: the most +recent period in a report is often still in progress, so its aggregate is +partial and shouldn't be read---or compared---as if it were finished. `mcrutils` +exposes this reasoning directly, with a business-day-aware notion of when a +period is "complete." + +`period_start_date()` and `period_end_date()` return the canonical first and last +calendar day of the period containing each date: + +```{r} +period_end_date(as.Date(c("2024-11-15", "2024-12-20")), unit = "month") +``` + +`period_is_complete()` tests whether the period containing each date is complete +as of a cutoff---typically the latest date in your data. Completeness is +business-day aware: a month whose only remaining days are weekends (or holidays) +already counts as complete. The default calendar is `"WeekendsOnly"`; pass a +national calendar such as `"UnitedStates"` for holiday accuracy, or `"Null"` to +require every calendar day to have elapsed. + +`example_sales` has no orders after 2024-12-20, so as of that date December 2024 +is still in progress while earlier months are complete: + +```{r} +latest <- max(example_sales$order_date) + +monthly <- example_sales |> + group_by(month = period_start_date(order_date, "month")) |> + summarise(units = sum(units_ordered), .groups = "drop") |> + mutate(complete = period_is_complete(month, "month", as_of = latest)) + +tail(monthly, n = 4) +``` + +Mapping the completeness flag to `alpha` and adding `scale_alpha_logical()` fades +the in-progress period so it reads as provisional: + +```{r period-completeness-alpha} +library(ggplot2) + +monthly |> + ggplot(aes(month, units, alpha = complete)) + + geom_col() + + scale_alpha_logical() + + labs(title = "Monthly units ordered", subtitle = "in-progress month faded") + + theme_minimal() +``` + +When you would rather drop the partial period than fade it---for example before a +year-over-year comparison---`filter_complete_periods()` keeps only the rows whose +period is complete. By default it measures completeness against the latest date +in the column, so you never discard a period the data already covers in full: + +```{r} +example_sales |> + filter_complete_periods(order_date, unit = "month") |> + summarise(rows = n(), last_complete_day = max(order_date)) +``` + +Finally, `last_complete_period_end()` and `last_complete_period_start()` give the +boundaries of the most recent complete period as of a cutoff, which is handy for +report titles and annotations: + +```{r} +last_complete_period_end(latest, unit = "month") +``` + + ## CAGR `mutate_cagrs()` adds columns with compound annual growth rates (CAGRs) for a vector of values over specified time periods, optionally grouped by one or more @@ -353,32 +423,8 @@ global_cum_daily_sales |> theme_minimal() ``` -Or we can look by-market as well, we just need to add another grouping variable -for the market, then facet the plot. - -```{r} -regional_cum_daily_sales <- sales_with_bizday |> - filter(order_date < ymd("2024-11-18")) |> - filter(month(order_date) == 11) |> - group_by(year = year(order_date), bizday_of_month, market) |> - summarise(units_ordered = sum(units_ordered), .groups = "drop") |> - group_by(year, market) |> - mutate(cumulative_units_ordered = cumsum(units_ordered)) - -head(regional_cum_daily_sales) -``` - -```{r} -regional_cum_daily_sales |> - ggplot(aes(bizday_of_month, cumulative_units_ordered, color = factor(year))) + - geom_line(linewidth = 1.2) + - facet_wrap(~market, ncol = 1) + - labs( - title = "Cumulative Units Ordered by Business Day of Month", - color = "Year" - ) + - theme_minimal() -``` +To break this out by market, add `market` as a grouping variable and +`facet_wrap(~ market)` to the plot. ## Year-to-date helpers From 66aecdadcb723fddc0c0a0b695c23c14e1a69e58 Mon Sep 17 00:00:00 2001 From: Michael Caselli Date: Tue, 14 Jul 2026 15:33:58 -0400 Subject: [PATCH 4/8] Clarify as_of in the vignette period-completeness example The monthly example passed as_of = max(order_date) to period_is_complete(), silently duplicating filter_complete_periods()'s default cutoff and obscuring which behavior is the default. Restructure as a two-part example: first filter_complete_periods() with defaults (December, still in progress, drops out), then a counter-example supplying an as_of in the next period ("data complete through January 1") so the trailing month counts as complete. The alpha-fade chart now states it reuses that same latest-date cutoff. Also correct the note that period_is_complete() defaults as_of to the current date. Co-Authored-By: Claude Opus 4.8 --- vignettes/mcrutils.Rmd | 52 +++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/vignettes/mcrutils.Rmd b/vignettes/mcrutils.Rmd index adbbaa3..762dab1 100644 --- a/vignettes/mcrutils.Rmd +++ b/vignettes/mcrutils.Rmd @@ -194,33 +194,50 @@ period_end_date(as.Date(c("2024-11-15", "2024-12-20")), unit = "month") ``` `period_is_complete()` tests whether the period containing each date is complete -as of a cutoff---typically the latest date in your data. Completeness is +as of a reference date (`as_of`, defaulting to the current date). Completeness is business-day aware: a month whose only remaining days are weekends (or holidays) already counts as complete. The default calendar is `"WeekendsOnly"`; pass a national calendar such as `"UnitedStates"` for holiday accuracy, or `"Null"` to require every calendar day to have elapsed. -`example_sales` has no orders after 2024-12-20, so as of that date December 2024 -is still in progress while earlier months are complete: +`filter_complete_periods()` keeps only the rows whose period is complete. By +default it judges completeness against the latest date in the data, so the +still-open final period drops out. `example_sales` has no orders after +2024-12-20, so December 2024 is treated as incomplete and excluded: ```{r} -latest <- max(example_sales$order_date) +example_sales |> + filter_complete_periods(order_date, unit = "month") |> + summarise(rows = n(), last_complete_day = max(order_date)) +``` -monthly <- example_sales |> - group_by(month = period_start_date(order_date, "month")) |> - summarise(units = sum(units_ordered), .groups = "drop") |> - mutate(complete = period_is_complete(month, "month", as_of = latest)) +Sometimes, though, you know a period is finished even though the data stops short +of its calendar end---for example the December feed closed on January 1, and the +absence of late-December orders reflects no business rather than missing data. +Supply an `as_of` in the following period and the trailing month counts as +complete (an *earlier* `as_of` is rejected, since it would discard periods the +data already covers in full): -tail(monthly, n = 4) +```{r} +example_sales |> + filter_complete_periods(order_date, unit = "month", as_of = as.Date("2025-01-01")) |> + summarise(rows = n(), last_complete_day = max(order_date)) ``` -Mapping the completeness flag to `alpha` and adding `scale_alpha_logical()` fades -the in-progress period so it reads as provisional: +To fade the in-progress period in a chart rather than drop it, flag each period +with `period_is_complete()`---here judged as of the latest order date, the same +cutoff `filter_complete_periods()` uses by default---and map the result to +`alpha` with `scale_alpha_logical()`: ```{r period-completeness-alpha} library(ggplot2) -monthly |> +latest <- max(example_sales$order_date) + +example_sales |> + group_by(month = period_start_date(order_date, "month")) |> + summarise(units = sum(units_ordered), .groups = "drop") |> + mutate(complete = period_is_complete(month, "month", as_of = latest)) |> ggplot(aes(month, units, alpha = complete)) + geom_col() + scale_alpha_logical() + @@ -228,17 +245,6 @@ monthly |> theme_minimal() ``` -When you would rather drop the partial period than fade it---for example before a -year-over-year comparison---`filter_complete_periods()` keeps only the rows whose -period is complete. By default it measures completeness against the latest date -in the column, so you never discard a period the data already covers in full: - -```{r} -example_sales |> - filter_complete_periods(order_date, unit = "month") |> - summarise(rows = n(), last_complete_day = max(order_date)) -``` - Finally, `last_complete_period_end()` and `last_complete_period_start()` give the boundaries of the most recent complete period as of a cutoff, which is handy for report titles and annotations: From 530d08d446a70d9f4ec5b448bd477deb6a47741b Mon Sep 17 00:00:00 2001 From: Michael Caselli Date: Tue, 14 Jul 2026 15:46:15 -0400 Subject: [PATCH 5/8] Use transparent group_by/summarize style for completeness examples Replace the filter_complete_periods() summaries with the more transparent group_by(month) |> summarise() style, in two pieces. The first omits as_of, so completeness is judged as of today and every historical month (including the partial December) reads complete; the second passes as_of = latest order date and a UnitedStates holiday calendar, correctly flagging December incomplete. Each summary shows month, units, last_order_in_period, and complete, making the reasoning visible. filter_complete_periods() is kept as a brief row-filter shortcut. Co-Authored-By: Claude Opus 4.8 --- vignettes/mcrutils.Rmd | 69 +++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/vignettes/mcrutils.Rmd b/vignettes/mcrutils.Rmd index 762dab1..fd9d116 100644 --- a/vignettes/mcrutils.Rmd +++ b/vignettes/mcrutils.Rmd @@ -200,44 +200,53 @@ already counts as complete. The default calendar is `"WeekendsOnly"`; pass a national calendar such as `"UnitedStates"` for holiday accuracy, or `"Null"` to require every calendar day to have elapsed. -`filter_complete_periods()` keeps only the rows whose period is complete. By -default it judges completeness against the latest date in the data, so the -still-open final period drops out. `example_sales` has no orders after -2024-12-20, so December 2024 is treated as incomplete and excluded: +Group the orders to monthly totals and flag each month with +`period_is_complete()`. With no `as_of`, completeness is judged as of the current +date, so for this historical dataset every month---including the partial +December, whose last order landed on the 20th---reads complete: ```{r} example_sales |> - filter_complete_periods(order_date, unit = "month") |> - summarise(rows = n(), last_complete_day = max(order_date)) + group_by(month = period_start_date(order_date, "month")) |> + summarise( + units = sum(units_ordered), + last_order_in_period = max(order_date), + .groups = "drop" + ) |> + mutate(complete = period_is_complete(month, "month")) |> + tail() ``` -Sometimes, though, you know a period is finished even though the data stops short -of its calendar end---for example the December feed closed on January 1, and the -absence of late-December orders reflects no business rather than missing data. -Supply an `as_of` in the following period and the trailing month counts as -complete (an *earlier* `as_of` is rejected, since it would discard periods the -data already covers in full): +That is rarely what you want for a report on historical data. Pass an +`as_of`---typically the latest date you actually have---to judge completeness as +of when the data was current, and optionally a national `calendar` so holidays, +not just weekends, count as non-working days. Now December is correctly flagged +incomplete: ```{r} -example_sales |> - filter_complete_periods(order_date, unit = "month", as_of = as.Date("2025-01-01")) |> - summarise(rows = n(), last_complete_day = max(order_date)) +latest <- max(example_sales$order_date) + +monthly <- example_sales |> + group_by(month = period_start_date(order_date, "month")) |> + summarise( + units = sum(units_ordered), + last_order_in_period = max(order_date), + .groups = "drop" + ) |> + mutate( + complete = period_is_complete(month, "month", as_of = latest, calendar = "UnitedStates") + ) + +tail(monthly) ``` -To fade the in-progress period in a chart rather than drop it, flag each period -with `period_is_complete()`---here judged as of the latest order date, the same -cutoff `filter_complete_periods()` uses by default---and map the result to -`alpha` with `scale_alpha_logical()`: +Mapping the completeness flag to `alpha` with `scale_alpha_logical()` fades the +in-progress month so it reads as provisional: ```{r period-completeness-alpha} library(ggplot2) -latest <- max(example_sales$order_date) - -example_sales |> - group_by(month = period_start_date(order_date, "month")) |> - summarise(units = sum(units_ordered), .groups = "drop") |> - mutate(complete = period_is_complete(month, "month", as_of = latest)) |> +monthly |> ggplot(aes(month, units, alpha = complete)) + geom_col() + scale_alpha_logical() + @@ -245,6 +254,16 @@ example_sales |> theme_minimal() ``` +To drop the incomplete periods instead of flagging them, +`filter_complete_periods()` applies the same test as a row filter, defaulting +`as_of` to the latest date in the data: + +```{r} +example_sales |> + filter_complete_periods(order_date, unit = "month") |> + summarise(last_order_kept = max(order_date)) +``` + Finally, `last_complete_period_end()` and `last_complete_period_start()` give the boundaries of the most recent complete period as of a cutoff, which is handy for report titles and annotations: From d2eda975baeb2c9bceca68dcb5ee42acc567850f Mon Sep 17 00:00:00 2001 From: Michael Caselli Date: Tue, 14 Jul 2026 16:40:59 -0400 Subject: [PATCH 6/8] Require explicit as_of; unify on as_of naming period_is_complete(), last_complete_period_start(), and last_complete_period_end() no longer default as_of to the current date. Completeness is a data-currency judgment, so silently keying it off Sys.Date() misreads cached/stale data (e.g. marking a past-but- partial month complete). Callers now state the reference explicitly; pass Sys.Date() to opt into wall-clock. filter_complete_periods() is unchanged (still defaults as_of to the max of its date column). For a consistent temporal-helper surface, rename most_recent_monday() / most_recent_sunday()'s ref_date argument to as_of (same concept: the date state is evaluated as of). Their current-date default is kept; positional calls are unaffected, only named ref_date = callers. Vignette recast to two references (wall-clock vs latest data date); tests added for the required-as_of errors and the as_of argument. Co-Authored-By: Claude Opus 4.8 --- NEWS.md | 12 ++++++++ R/most_recent_day_of_week.R | 28 +++++++++---------- R/period_completeness.R | 28 +++++++++++++++---- man/last_complete_period.Rd | 9 +++--- man/most_recent_x_day.Rd | 8 +++--- man/period_is_complete.Rd | 7 +++-- tests/testthat/test-most_recent_day_of_week.R | 5 ++++ tests/testthat/test-period_completeness.R | 11 ++++++++ vignettes/mcrutils.Rmd | 21 +++++++------- 9 files changed, 89 insertions(+), 40 deletions(-) diff --git a/NEWS.md b/NEWS.md index 2d42a08..65b98f8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -19,6 +19,18 @@ date is reached (not strictly after it). For pure calendar-day semantics use `calendar = "Null"` (the QuantLib id is `"Null"`, not `"NullCalendar"`). `NA` dates propagate to `NA` (and are dropped by `filter_complete_periods()`). +- `period_is_complete()`, `last_complete_period_start()`, and + `last_complete_period_end()` require an explicit `as_of`; there is no + wall-clock default, so completeness is never judged against the current date + unless you ask for it (pass `Sys.Date()` to opt in). `filter_complete_periods()` + still defaults `as_of` to the maximum of its date column. + +## Behavior changes +- `most_recent_monday()` / `most_recent_sunday()`: the `ref_date` argument is + renamed `as_of`, matching the period-completeness functions (both name the + date that state is evaluated as of). The default (current date) is unchanged + and positional calls are unaffected, but callers passing `ref_date =` by name + must switch to `as_of =`. # mcrutils 0.0.0.9013 diff --git a/R/most_recent_day_of_week.R b/R/most_recent_day_of_week.R index c029771..8428ecf 100644 --- a/R/most_recent_day_of_week.R +++ b/R/most_recent_day_of_week.R @@ -3,8 +3,8 @@ #' This function returns the date of the most recent Sunday or Monday that #' is on or before the current date or a specified reference date. #' -#' @param ref_date A date object or a string representing a date. -#' Defaults to NULL, which uses the current date. +#' @param as_of A date object or a string representing the date to evaluate +#' relative to. Defaults to NULL, which uses the current date. #' #' @return A Date object representing the most recent Sunday or Monday #' @examples @@ -17,26 +17,26 @@ NULL #' @rdname most_recent_x_day #' @export -most_recent_monday <- function(ref_date = NULL) { - if (!is.null(ref_date)) { - ref_date <- as.Date(ref_date) +most_recent_monday <- function(as_of = NULL) { + if (!is.null(as_of)) { + as_of <- as.Date(as_of) } else { - ref_date <- Sys.Date() + as_of <- Sys.Date() } - weekday <- as.integer(format(ref_date, "%u")) # 1 = Monday, 7 = Sunday + weekday <- as.integer(format(as_of, "%u")) # 1 = Monday, 7 = Sunday days_to_subtract <- ifelse(weekday == 1, 0, weekday - 1) - ref_date - days_to_subtract + as_of - days_to_subtract } #' @rdname most_recent_x_day #' @export -most_recent_sunday <- function(ref_date = NULL) { - if (!is.null(ref_date)) { - ref_date <- as.Date(ref_date) +most_recent_sunday <- function(as_of = NULL) { + if (!is.null(as_of)) { + as_of <- as.Date(as_of) } else { - ref_date <- Sys.Date() + as_of <- Sys.Date() } - weekday <- as.integer(format(ref_date, "%u")) # 1 = Monday, 7 = Sunday + weekday <- as.integer(format(as_of, "%u")) # 1 = Monday, 7 = Sunday days_to_subtract <- ifelse(weekday == 7, 0, weekday) - ref_date - days_to_subtract + as_of - days_to_subtract } diff --git a/R/period_completeness.R b/R/period_completeness.R index a153069..1997ece 100644 --- a/R/period_completeness.R +++ b/R/period_completeness.R @@ -137,7 +137,10 @@ period_end_date <- function(date, unit = "month", #' @param unit Period size, one of `"week"`, `"month"`, `"quarter"`, #' or `"year"`. #' @param as_of Reference cutoff date (Date or coercible), length 1 or the length -#' of `date`. Defaults to [Sys.Date()]. +#' of `date`. Required---there is no default, so completeness is never judged +#' silently against the current date. Pass `max()` of your data's date column +#' for completeness relative to how current the data is, or [Sys.Date()] for +#' the current date. #' @param calendar (character) A QuantLib calendar id (see [qlcal::calendars]). #' Defaults to `"WeekendsOnly"`. #' @param week_start Integer 1-7 giving the first day of the week (only used when @@ -162,10 +165,16 @@ period_end_date <- function(date, unit = "month", #' as_of = as.Date("2025-05-30"), #' calendar = "Null" #' ) -period_is_complete <- function(date, unit = "month", as_of = Sys.Date(), +period_is_complete <- function(date, unit = "month", as_of, calendar = "WeekendsOnly", week_start = getOption("lubridate.week.start", 7)) { unit <- validate_period_unit(unit) + if (missing(as_of)) { + cli::cli_abort(c( + "{.arg as_of} is required.", + "i" = "Supply the reference cutoff date, e.g. {.code max()} of your data's date column, or {.code Sys.Date()} for the current date." + )) + } as_of <- as.Date(as_of) end <- period_end_date(date, unit = unit, week_start = week_start) @@ -261,8 +270,9 @@ filter_complete_periods <- function(data, date_col, unit = "month", as_of = NULL #' [period_is_complete()], so `calendar` selects the policy exactly as it does #' there. #' -#' @param as_of Reference cutoff date(s) (Date or coercible). Defaults to -#' [Sys.Date()]. +#' @param as_of Reference cutoff date(s) (Date or coercible). Required---there is +#' no default. Use the latest date in your data for completeness relative to how +#' current the data is, or [Sys.Date()] for the current date. #' @param unit Period size, one of `"week"`, `"month"`, `"quarter"`, #' or `"year"`. #' @param calendar (character) A QuantLib calendar id (see [qlcal::calendars]). @@ -281,10 +291,16 @@ NULL #' @rdname last_complete_period #' @export -last_complete_period_end <- function(as_of = Sys.Date(), unit = "month", +last_complete_period_end <- function(as_of, unit = "month", calendar = "WeekendsOnly", week_start = getOption("lubridate.week.start", 7)) { unit <- validate_period_unit(unit) + if (missing(as_of)) { + cli::cli_abort(c( + "{.arg as_of} is required.", + "i" = "Supply the reference cutoff date, e.g. the latest date in your data, or {.code Sys.Date()} for the current date." + )) + } as_of <- as.Date(as_of) end_current <- period_end_date(as_of, unit = unit, week_start = week_start) @@ -305,7 +321,7 @@ last_complete_period_end <- function(as_of = Sys.Date(), unit = "month", #' @rdname last_complete_period #' @export -last_complete_period_start <- function(as_of = Sys.Date(), unit = "month", +last_complete_period_start <- function(as_of, unit = "month", calendar = "WeekendsOnly", week_start = getOption("lubridate.week.start", 7)) { end <- last_complete_period_end( diff --git a/man/last_complete_period.Rd b/man/last_complete_period.Rd index a583e7a..2a716a1 100644 --- a/man/last_complete_period.Rd +++ b/man/last_complete_period.Rd @@ -7,22 +7,23 @@ \title{Boundary of the most recent complete period as of a cutoff} \usage{ last_complete_period_end( - as_of = Sys.Date(), + as_of, unit = "month", calendar = "WeekendsOnly", week_start = getOption("lubridate.week.start", 7) ) last_complete_period_start( - as_of = Sys.Date(), + as_of, unit = "month", calendar = "WeekendsOnly", week_start = getOption("lubridate.week.start", 7) ) } \arguments{ -\item{as_of}{Reference cutoff date(s) (Date or coercible). Defaults to -\code{\link[=Sys.Date]{Sys.Date()}}.} +\item{as_of}{Reference cutoff date(s) (Date or coercible). Required---there is +no default. Use the latest date in your data for completeness relative to how +current the data is, or \code{\link[=Sys.Date]{Sys.Date()}} for the current date.} \item{unit}{Period size, one of \code{"week"}, \code{"month"}, \code{"quarter"}, or \code{"year"}.} diff --git a/man/most_recent_x_day.Rd b/man/most_recent_x_day.Rd index 9c291c0..45529fa 100644 --- a/man/most_recent_x_day.Rd +++ b/man/most_recent_x_day.Rd @@ -6,13 +6,13 @@ \alias{most_recent_sunday} \title{Get the date of the prior Sunday or Monday} \usage{ -most_recent_monday(ref_date = NULL) +most_recent_monday(as_of = NULL) -most_recent_sunday(ref_date = NULL) +most_recent_sunday(as_of = NULL) } \arguments{ -\item{ref_date}{A date object or a string representing a date. -Defaults to NULL, which uses the current date.} +\item{as_of}{A date object or a string representing the date to evaluate +relative to. Defaults to NULL, which uses the current date.} } \value{ A Date object representing the most recent Sunday or Monday diff --git a/man/period_is_complete.Rd b/man/period_is_complete.Rd index a64d99d..3dea5ca 100644 --- a/man/period_is_complete.Rd +++ b/man/period_is_complete.Rd @@ -7,7 +7,7 @@ period_is_complete( date, unit = "month", - as_of = Sys.Date(), + as_of, calendar = "WeekendsOnly", week_start = getOption("lubridate.week.start", 7) ) @@ -20,7 +20,10 @@ identifies the period to test.} or \code{"year"}.} \item{as_of}{Reference cutoff date (Date or coercible), length 1 or the length -of \code{date}. Defaults to \code{\link[=Sys.Date]{Sys.Date()}}.} +of \code{date}. Required---there is no default, so completeness is never judged +silently against the current date. Pass \code{max()} of your data's date column +for completeness relative to how current the data is, or \code{\link[=Sys.Date]{Sys.Date()}} for +the current date.} \item{calendar}{(character) A QuantLib calendar id (see \link[qlcal:calendars]{qlcal::calendars}). Defaults to \code{"WeekendsOnly"}.} diff --git a/tests/testthat/test-most_recent_day_of_week.R b/tests/testthat/test-most_recent_day_of_week.R index 0cfa29f..8bfdb0b 100644 --- a/tests/testthat/test-most_recent_day_of_week.R +++ b/tests/testthat/test-most_recent_day_of_week.R @@ -15,3 +15,8 @@ test_that("most_recent_monday() works", { as.Date(c("2025-12-29", "2026-01-05", "2026-01-05")) ) }) + +test_that("the reference date is passed via the as_of argument", { + expect_equal(most_recent_monday(as_of = "2024-06-15"), as.Date("2024-06-10")) + expect_equal(most_recent_sunday(as_of = "2024-06-15"), as.Date("2024-06-09")) +}) diff --git a/tests/testthat/test-period_completeness.R b/tests/testthat/test-period_completeness.R index c692f23..8c931c7 100644 --- a/tests/testthat/test-period_completeness.R +++ b/tests/testthat/test-period_completeness.R @@ -113,6 +113,11 @@ test_that("period_is_complete() errors on an invalid unit", { ) }) +test_that("period_is_complete() requires an explicit as_of", { + # There is no wall-clock default; the caller must state the reference. + expect_error(period_is_complete(as.Date("2025-05-15"), "month"), "is required") +}) + # filter_complete_periods() -------------------------------------------------- test_that("filter_complete_periods() drops a partial trailing month (common case)", { @@ -195,6 +200,12 @@ test_that("last_complete_period_end() is vectorized over as_of", { expect_equal(out, as.Date(c("2025-03-31", "2025-06-30"))) }) +test_that("last_complete_period_*() require an explicit as_of", { + # No wall-clock default; missingness also propagates through _start to _end. + expect_error(last_complete_period_end(unit = "month"), "is required") + expect_error(last_complete_period_start(unit = "month"), "is required") +}) + # scale_alpha_logical() ------------------------------------------------------ test_that("scale_alpha_logical() returns a ggplot2 scale", { diff --git a/vignettes/mcrutils.Rmd b/vignettes/mcrutils.Rmd index fd9d116..364c5ef 100644 --- a/vignettes/mcrutils.Rmd +++ b/vignettes/mcrutils.Rmd @@ -194,16 +194,17 @@ period_end_date(as.Date(c("2024-11-15", "2024-12-20")), unit = "month") ``` `period_is_complete()` tests whether the period containing each date is complete -as of a reference date (`as_of`, defaulting to the current date). Completeness is +as of a reference date, `as_of`. There is no default: you always state the +reference, because the right one depends on your data. Completeness is business-day aware: a month whose only remaining days are weekends (or holidays) already counts as complete. The default calendar is `"WeekendsOnly"`; pass a national calendar such as `"UnitedStates"` for holiday accuracy, or `"Null"` to require every calendar day to have elapsed. -Group the orders to monthly totals and flag each month with -`period_is_complete()`. With no `as_of`, completeness is judged as of the current -date, so for this historical dataset every month---including the partial -December, whose last order landed on the 20th---reads complete: +Group the orders to monthly totals and flag each month. Passing `Sys.Date()` +asks "is this period over in real time?" For a historical extract like +`example_sales` every month is long past, so all read complete---including the +partial December, whose last order landed on the 20th: ```{r} example_sales |> @@ -213,14 +214,14 @@ example_sales |> last_order_in_period = max(order_date), .groups = "drop" ) |> - mutate(complete = period_is_complete(month, "month")) |> + mutate(complete = period_is_complete(month, "month", as_of = Sys.Date())) |> tail() ``` -That is rarely what you want for a report on historical data. Pass an -`as_of`---typically the latest date you actually have---to judge completeness as -of when the data was current, and optionally a national `calendar` so holidays, -not just weekends, count as non-working days. Now December is correctly flagged +That is rarely what you want when reporting on a cached extract. Judge +completeness relative to how current the data actually is by passing the latest +date you have as `as_of`, and optionally a national `calendar` so holidays, not +just weekends, count as non-working days. Now December is correctly flagged incomplete: ```{r} From ea6aa62d7f290287bf427c6911e21b6d0a40cab4 Mon Sep 17 00:00:00 2001 From: Michael Caselli Date: Wed, 15 Jul 2026 06:46:05 -0400 Subject: [PATCH 7/8] Make vignette completeness examples demonstrate their arguments Drop the design-decision prose and the two inert examples (Sys.Date() where everything reads complete, and a UnitedStates calendar that changed nothing at as_of = 2024-12-20). Replace with examples whose output visibly reflects the argument shown: a vector as_of returning TRUE/TRUE/FALSE, and a holiday example on the July-4th work week where "UnitedStates" reports complete but "WeekendsOnly" does not. Co-Authored-By: Claude Opus 4.8 --- vignettes/mcrutils.Rmd | 58 ++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/vignettes/mcrutils.Rmd b/vignettes/mcrutils.Rmd index 364c5ef..3c6bf23 100644 --- a/vignettes/mcrutils.Rmd +++ b/vignettes/mcrutils.Rmd @@ -194,35 +194,39 @@ period_end_date(as.Date(c("2024-11-15", "2024-12-20")), unit = "month") ``` `period_is_complete()` tests whether the period containing each date is complete -as of a reference date, `as_of`. There is no default: you always state the -reference, because the right one depends on your data. Completeness is -business-day aware: a month whose only remaining days are weekends (or holidays) -already counts as complete. The default calendar is `"WeekendsOnly"`; pass a -national calendar such as `"UnitedStates"` for holiday accuracy, or `"Null"` to -require every calendar day to have elapsed. - -Group the orders to monthly totals and flag each month. Passing `Sys.Date()` -asks "is this period over in real time?" For a historical extract like -`example_sales` every month is long past, so all read complete---including the -partial December, whose last order landed on the 20th: +as of a cutoff date, `as_of`. It is business-day aware: a period whose only +remaining days are weekends counts as complete. As of Friday 2025-05-30, for +instance, April and May are complete---May's only unelapsed day, the 31st, is a +Saturday---but June is not: ```{r} -example_sales |> - group_by(month = period_start_date(order_date, "month")) |> - summarise( - units = sum(units_ordered), - last_order_in_period = max(order_date), - .groups = "drop" - ) |> - mutate(complete = period_is_complete(month, "month", as_of = Sys.Date())) |> - tail() +period_is_complete( + as.Date(c("2025-04-15", "2025-05-15", "2025-06-15")), + unit = "month", + as_of = as.Date("2025-05-30") +) ``` -That is rarely what you want when reporting on a cached extract. Judge -completeness relative to how current the data actually is by passing the latest -date you have as `as_of`, and optionally a national `calendar` so holidays, not -just weekends, count as non-working days. Now December is correctly flagged -incomplete: +Pass a national `calendar` so public holidays, not just weekends, count as +non-working. Take the work week of 2025-06-30, whose last weekday is Friday the +4th---U.S. Independence Day. As of Thursday the 3rd, only that holiday and the +weekend remain, so the week is already complete under `"UnitedStates"` but not +under the default `"WeekendsOnly"`: + +```{r} +thursday <- as.Date("2025-07-03") +period_is_complete(thursday, "week", as_of = thursday, + calendar = "UnitedStates", week_start = 1) +period_is_complete(thursday, "week", as_of = thursday, + calendar = "WeekendsOnly", week_start = 1) +``` + +(Use `calendar = "Null"` to ignore business days and require every calendar day +of the period to have elapsed instead.) + +In a report, group to the period and flag each period as of the latest date in +the data. Orders in `example_sales` run only through 2024-12-20, so the partial +final month reads incomplete while every earlier month is complete: ```{r} latest <- max(example_sales$order_date) @@ -234,9 +238,7 @@ monthly <- example_sales |> last_order_in_period = max(order_date), .groups = "drop" ) |> - mutate( - complete = period_is_complete(month, "month", as_of = latest, calendar = "UnitedStates") - ) + mutate(complete = period_is_complete(month, "month", as_of = latest)) tail(monthly) ``` From deda3dbb966359b0b1c0931e8174972b56ae23d4 Mon Sep 17 00:00:00 2001 From: Michael Caselli Date: Wed, 15 Jul 2026 07:40:20 -0400 Subject: [PATCH 8/8] vignette improvements --- vignettes/mcrutils.Rmd | 332 +++++++++++++++++++++++------------------ 1 file changed, 184 insertions(+), 148 deletions(-) diff --git a/vignettes/mcrutils.Rmd b/vignettes/mcrutils.Rmd index 3c6bf23..8bb3fe9 100644 --- a/vignettes/mcrutils.Rmd +++ b/vignettes/mcrutils.Rmd @@ -81,7 +81,8 @@ dates <- seq(as.Date("2022-01-01"), as.Date("2022-06-30"), by = "day") orders <- data.frame( account_id = sample(letters[1:10], n, replace = TRUE), order_date = sample(dates, n, replace = TRUE) -) |> arrange(order_date) +) |> + arrange(order_date) orders |> glimpse() ``` @@ -100,7 +101,12 @@ printed output here). ```{r} orders |> - accounts_by_status(account_id, order_date, by = "month", with_counts = TRUE) |> + accounts_by_status( + account_id, + order_date, + by = "month", + with_counts = TRUE + ) |> select(period_start, starts_with("n_")) ``` @@ -129,7 +135,12 @@ library(dplyr, warn.conflicts = FALSE) library(tidyr) example_sales |> - accounts_by_status(account_id, order_date, with_counts = TRUE, by = "quarter") |> + accounts_by_status( + account_id, + order_date, + with_counts = TRUE, + by = "quarter" + ) |> select(period_start, starts_with("n_")) |> # negate the lost counts for visualization mutate(across(contains("lost"), ~ -.x)) |> @@ -138,15 +149,17 @@ example_sales |> mutate(status = stringr::str_remove(status, "n_")) |> ggplot(aes(period_start, count, color = status)) + geom_line(linewidth = 1.2) + - scale_color_manual(values = c( - "active" = "#1f78b4", - "new" = "#33a02c", - "returning" = "#a6cee3", - "temporarily_lost" = "#fb9a99", - "terminally_lost" = "#e31a1c", - "regained" = "#b2df8a", - "cumulative" = "#999999" - )) + + scale_color_manual( + values = c( + "active" = "#1f78b4", + "new" = "#33a02c", + "returning" = "#a6cee3", + "temporarily_lost" = "#fb9a99", + "terminally_lost" = "#e31a1c", + "regained" = "#b2df8a", + "cumulative" = "#999999" + ) + ) + theme_minimal() ``` @@ -170,7 +183,8 @@ You can suppress the dashed lines for incomplete periods with with ```{r plot_accounts_2} example_sales |> plot_accounts_by_status( - account_id, order_date, + account_id, + order_date, by = "quarter", force_final_period_complete = TRUE, include_cumulative = FALSE @@ -178,136 +192,6 @@ example_sales |> ``` -## Period completeness - -The dashed final period above is one instance of a recurring problem: the most -recent period in a report is often still in progress, so its aggregate is -partial and shouldn't be read---or compared---as if it were finished. `mcrutils` -exposes this reasoning directly, with a business-day-aware notion of when a -period is "complete." - -`period_start_date()` and `period_end_date()` return the canonical first and last -calendar day of the period containing each date: - -```{r} -period_end_date(as.Date(c("2024-11-15", "2024-12-20")), unit = "month") -``` - -`period_is_complete()` tests whether the period containing each date is complete -as of a cutoff date, `as_of`. It is business-day aware: a period whose only -remaining days are weekends counts as complete. As of Friday 2025-05-30, for -instance, April and May are complete---May's only unelapsed day, the 31st, is a -Saturday---but June is not: - -```{r} -period_is_complete( - as.Date(c("2025-04-15", "2025-05-15", "2025-06-15")), - unit = "month", - as_of = as.Date("2025-05-30") -) -``` - -Pass a national `calendar` so public holidays, not just weekends, count as -non-working. Take the work week of 2025-06-30, whose last weekday is Friday the -4th---U.S. Independence Day. As of Thursday the 3rd, only that holiday and the -weekend remain, so the week is already complete under `"UnitedStates"` but not -under the default `"WeekendsOnly"`: - -```{r} -thursday <- as.Date("2025-07-03") -period_is_complete(thursday, "week", as_of = thursday, - calendar = "UnitedStates", week_start = 1) -period_is_complete(thursday, "week", as_of = thursday, - calendar = "WeekendsOnly", week_start = 1) -``` - -(Use `calendar = "Null"` to ignore business days and require every calendar day -of the period to have elapsed instead.) - -In a report, group to the period and flag each period as of the latest date in -the data. Orders in `example_sales` run only through 2024-12-20, so the partial -final month reads incomplete while every earlier month is complete: - -```{r} -latest <- max(example_sales$order_date) - -monthly <- example_sales |> - group_by(month = period_start_date(order_date, "month")) |> - summarise( - units = sum(units_ordered), - last_order_in_period = max(order_date), - .groups = "drop" - ) |> - mutate(complete = period_is_complete(month, "month", as_of = latest)) - -tail(monthly) -``` - -Mapping the completeness flag to `alpha` with `scale_alpha_logical()` fades the -in-progress month so it reads as provisional: - -```{r period-completeness-alpha} -library(ggplot2) - -monthly |> - ggplot(aes(month, units, alpha = complete)) + - geom_col() + - scale_alpha_logical() + - labs(title = "Monthly units ordered", subtitle = "in-progress month faded") + - theme_minimal() -``` - -To drop the incomplete periods instead of flagging them, -`filter_complete_periods()` applies the same test as a row filter, defaulting -`as_of` to the latest date in the data: - -```{r} -example_sales |> - filter_complete_periods(order_date, unit = "month") |> - summarise(last_order_kept = max(order_date)) -``` - -Finally, `last_complete_period_end()` and `last_complete_period_start()` give the -boundaries of the most recent complete period as of a cutoff, which is handy for -report titles and annotations: - -```{r} -last_complete_period_end(latest, unit = "month") -``` - - -## CAGR -`mutate_cagrs()` adds columns with compound annual growth rates (CAGRs) for a -vector of values over specified time periods, optionally grouped by one or more -variables. - -Here we'll first aggregate the `example_sales` data to get monthly sales volume -by market, then use `mutate_cagrs()` to calculate 1-, 2-, and 3-month CAGRs for -each market. - -```{r} -library(lubridate, warn.conflicts = FALSE) - -example_sales |> - group_by( - market, - month = lubridate::floor_date(order_date, unit = "month") - ) |> - summarize( - volume = sum(units_ordered), - .groups = "drop_last" - ) |> - mutate_cagrs( - volume, - month, - group_vars = market, - periods = c(1:3) - ) |> - # peek at the first 4 rows for each market - slice_head(n = 4, by = market) -``` - - ## Business day evaluation `mcrutils` provides several functions for working with business days, including `is_bizday()`, `adjust_to_bizday()`, `bizdays_between()`, `periodic_bizdays()`, @@ -362,6 +246,7 @@ close, we just need to eliminate the space in "United States"). ```{r head_example_sales} library(dplyr, warn.conflicts = FALSE) +library(lubridate, warn.conflicts = FALSE) library(purrr) library(stringr) @@ -386,7 +271,8 @@ bizday_lookup <- tibble( tidyr::expand_grid(calendar = unique(sales$calendar)) |> mutate( adjusted_date = purrr::map2_vec( - .data$date, .data$calendar, + .data$date, + .data$calendar, \(date, calendar) adjust_to_bizday(date, calendar) ), # calculate the business day of month for each date in each market @@ -415,7 +301,10 @@ Now we can join the lookup table to the sales data. ```{r} sales_with_bizday <- sales |> - left_join(bizday_lookup, by = c("order_date" = "date", "calendar" = "calendar")) + left_join( + bizday_lookup, + by = c("order_date" = "date", "calendar" = "calendar") + ) head(sales_with_bizday) ``` @@ -508,6 +397,153 @@ c("2024-01-01", "2024-02-29", "2025-07-15") |> + +## Period completeness + +The most recent period in a report is often still in progress, so its aggregate is +partial and shouldn't be read---or compared---as if it were finished. Note that our churn chart above shows the final incomplete period as dashed for just this reason. `mcrutils` provides various utilities to compute period completeness with a business-day-aware notion of when a +period is "complete." + +`period_start_date()` and `period_end_date()` return the canonical first and last +calendar day of the period containing each date: + +```{r} +period_end_date(as.Date(c("2024-11-15", "2024-12-20")), unit = "month") +``` + +`period_is_complete()` tests whether the period containing each date is complete +as of a cutoff date, `as_of`. It is business-day aware: a period whose only +remaining days are weekends counts as complete. As of Friday 2025-05-30, for +instance, April and May are complete---May's only unelapsed day, the 31st, is a +Saturday---but June is not: + +```{r} +period_is_complete( + as.Date(c("2025-04-15", "2025-05-15", "2025-06-15")), + unit = "month", + as_of = as.Date("2025-05-30") +) +``` + +Pass a national `calendar` so public holidays, not just weekends, count as +non-working. Take the work week of 2025-06-30, whose last weekday is Friday the +4th---U.S. Independence Day. As of Thursday the 3rd, only that holiday and the +weekend remain, so the week is already complete under `"UnitedStates"` but not +under the default `"WeekendsOnly"`: + +```{r} +thursday <- as.Date("2025-07-03") +period_is_complete( + thursday, + "week", + as_of = thursday, + calendar = "UnitedStates", + week_start = 1 +) +period_is_complete( + thursday, + "week", + as_of = thursday, + calendar = "WeekendsOnly", + week_start = 1 +) +``` + +(Use `calendar = "Null"` to ignore business days and require every calendar day +of the period to have elapsed instead.) + +A common use case would be in a recurring report where the data is refereshed +e.g. daily, and we want to provide summaries for each period. We can use +`period_is_complete()` to flag each period. Assuming the query was run on +2025-12-21, we can group the `example_sales` data by month and summarize the +total units ordered and the last order date in each month, then flag whether +each month is complete as of the query date. + +```{r} +query_date <- as.Date("2025-12-21") + +monthly <- example_sales |> + group_by(month = period_start_date(order_date, "month")) |> + summarise( + units = sum(units_ordered), + last_order_in_period = max(order_date), + .groups = "drop" + ) |> + mutate( + complete = period_is_complete( + month, + unit = "month", + as_of = query_date, + calendar = "UnitedStates" + ) + ) + +tail(monthly) +``` + +Mapping the completeness flag to `alpha` with `scale_alpha_logical()` fades the +in-progress month so it reads as provisional: + +```{r period-completeness-alpha} +library(ggplot2) + +monthly |> + ggplot(aes(month, units, alpha = complete)) + + geom_col() + + scale_alpha_logical() + + labs(title = "Monthly units ordered", subtitle = "in-progress month faded") + + theme_minimal() +``` + +To drop the incomplete periods instead of flagging them, +`filter_complete_periods()` applies the same test as a row filter, defaulting +`as_of` to the latest date in the data: + +```{r} +example_sales |> + filter_complete_periods(order_date, unit = "month") |> + summarise(last_order_kept = max(order_date)) +``` + +Finally, `last_complete_period_end()` and `last_complete_period_start()` give the +boundaries of the most recent complete period as of a cutoff, which is handy for as passing to a filter or for annotations: + +```{r} +last_complete_period_end(max(example_sales$order_date), unit = "month") +``` + + +## CAGR +`mutate_cagrs()` adds columns with compound annual growth rates (CAGRs) for a +vector of values over specified time periods, optionally grouped by one or more +variables. + +Here we'll first aggregate the `example_sales` data to get monthly sales volume +by market, then use `mutate_cagrs()` to calculate 1-, 2-, and 3-month CAGRs for +each market. + +```{r} + +example_sales |> + group_by( + market, + month = lubridate::floor_date(order_date, unit = "month") + ) |> + summarize( + volume = sum(units_ordered), + .groups = "drop_last" + ) |> + mutate_cagrs( + volume, + month, + group_vars = market, + periods = c(1:3) + ) |> + # peek at the first 4 rows for each market + slice_head(n = 4, by = market) +``` + + # Visualization ## Auto-formatted datattables @@ -525,9 +561,9 @@ with an option to preserve selected acronyms in uppercase. ```{r guess_col_fmts_example, screenshot.opts=list(vwidth= 800, vheight = 600), message=FALSE} tribble( - ~market_name, ~ytd_revenue, ~cagr_pct, ~num_accounts, - "North", 1023.2456, 0.0512, 145, - "West", 150.2397, 0.1034, 28 + ~market_name , ~ytd_revenue , ~cagr_pct , ~num_accounts , + "North" , 1023.2456 , 0.0512 , 145 , + "West" , 150.2397 , 0.1034 , 28 ) |> rename_cols_for_display(all_caps = c("YTD", "CAGR")) |> auto_dt(numeric_digits = 1, pct_digits = 0)