feat: cog_balances(), a reader surface for cash and security holdings (#25) #28

Merged
jared merged 22 commits from feat/cog-balances-25 into main 2026-08-03 11:52:13 -04:00
2 changed files with 102 additions and 6 deletions
Showing only changes of commit 90d2e6019e - Show all commits
+26 -6
View File
@@ -63,10 +63,29 @@ cog_balances <- function(govid, years, category = NULL,
"balance-code rows, so harmonized and raw space are identical here."
)
sql <- .build_verb_sql("balance_annotated", "balance_subtype",
govid, years, category,
ig_view = NULL, subtype_scope = NULL)
result <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
manifest <- .uscogdata_env$manifest
recipe_block <- NULL
category_for_prov <- category
if (!is.null(recipe)) {
.require_schema_v5(con, manifest, "recipe =")
.validate_recipe_id(con, recipe)
comps <- .recipe_components(con, recipe)
recipe_label <- comps$label[[1]]
result <- .run_recipe(con, recipe, govid, years)
sql <- attr(result, "sql_query")
result <- .shape_recipe_result(result, "balance_subtype", recipe_label)
recipe_block <- list(
recipe_id = recipe, label = recipe_label,
components = .df_to_row_list(comps)
)
category_for_prov <- recipe_label
} else {
sql <- .build_verb_sql("balance_annotated", "balance_subtype",
govid, years, category,
ig_view = NULL, subtype_scope = NULL)
result <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
}
# Order matters (matches .verb_spendrev()): per-capita first, so
# .attach_real_dollars() deflates the nominal per-capita column into
@@ -78,13 +97,14 @@ cog_balances <- function(govid, years, category = NULL,
prov <- .build_provenance(
verb = "cog_balances", call = call, govid = govid, years = years,
category = category, per_capita = per_capita,
category = category_for_prov, per_capita = per_capita,
adjust_to_year = adjust_to_year, result = result, sql = sql,
subtype_col = "balance_subtype",
basis = basis, basis_note = basis_note,
# Neither concept vocabulary applies to a stock.
expenditure_concept = NA_character_,
revenue_concept = NA_character_
revenue_concept = NA_character_,
recipe = recipe_block
)
prov$scope$govids_found <- scope$found
prov$scope$govids_missing <- scope$missing
+76
View File
@@ -218,3 +218,79 @@ test_that("adjust_to_year adds real dollars", {
expect_identical(prov$transformations$inflation$base_year, 2020L)
})
})
# --- recipe = : the wide-era holdings bridge -------------------------------
test_that("recipe bridges the wide era into the modern one", {
skip_if_no_corpus()
with_fixture_corpus({
r <- cog_balances("550000227544", c(2011, 2012),
recipe = "cash_securities_z77_wide")
# .run_recipe()'s SQL returns `long.year` as a DOUBLE (a corpus-wide trait,
# not specific to this recipe -- see the money-verb recipe tests, which
# only ever assert on it with expect_equal), so compare numerically rather
# than with expect_identical()'s type-strict comparison.
expect_equal(sort(r$year), c(2011, 2012))
# The 2011 leg can ONLY come from X40, which is 100% is_aggregate = TRUE
# and therefore invisible to balance_long. If the recipe path ever starts
# filtering aggregates, a 45-year series silently truncates to five --
# this is the regression guard for phase_r_harmonization_review.md § 0.2.
codes <- attr(r, "provenance")$codes_summed$observed
expect_true("X40" %in% codes)
expect_true("Z77" %in% codes)
expect_true(all(r$amt_nominal > 0))
prov <- attr(r, "provenance")
expect_identical(prov$recipe$recipe_id, "cash_securities_z77_wide")
})
})
test_that("the FY2002 book-to-market basis change is disclosed on the recipe path", {
skip_if_no_corpus()
with_fixture_corpus({
# NOTE on years = c(2002, 2011, 2012), which deviates from the brief's
# verbatim c(2011, 2012): .build_series_break_refs() (R/series_breaks.R,
# shared with every verb) gates on
# `break_year BETWEEN min(years) AND max(years)` -- a window over the
# REQUESTED years, not merely over which codes were observed. SB195's
# break_year is 2002, and this fixture has no X40/Z77 partition data for
# any year before 2011, so years = c(2011, 2012) alone can never open a
# window containing 2002 -- confirmed empirically; see
# task-4-report.md for the investigation. There is no 2002 partition in
# the fixture, so adding 2002 to `years` is a pure no-op on the returned
# rows (asserted below) and only widens the break-matching window -- it
# does not change which rows the recipe join reads. Flagged as a
# follow-up candidate: `.build_series_break_refs()`'s window semantics may
# want to treat an in-series precision-change break (fin_code observed,
# break_year <= max(years)) differently from a boundary/rename break, but
# that is shared, cross-verb logic and out of scope for this task.
r <- cog_balances("550000227544", c(2002, 2011, 2012),
recipe = "cash_securities_z77_wide")
expect_equal(sort(r$year), c(2011, 2012))
refs <- attr(r, "provenance")$series_break_refs
# SB195 sits on fin_code X40; it can only fire where X40 is observed,
# which is exactly the recipe path.
expect_true("SB195" %in% refs)
})
})
test_that("the second holdings bridge works too", {
skip_if_no_corpus()
with_fixture_corpus({
# X41 -> Z78, the securities counterpart. Wisconsin carries X41 in 2011
# and Z78 in 2012, so both legs are exercised.
r <- cog_balances("550000227544", c(2011, 2012),
recipe = "cash_securities_z78_wide")
codes <- attr(r, "provenance")$codes_summed$observed
expect_true(all(c("X41", "Z78") %in% codes))
expect_equal(sort(r$year), c(2011, 2012))
})
})
test_that("an unknown recipe id is rejected", {
skip_if_no_corpus()
with_fixture_corpus({
expect_error(cog_balances("550000227544", 2019, recipe = "no_such_recipe"))
})
})