Skip to content

Feedback from a budget-execution monitoring use case (obligation pace + OpenOMB join) #39

@abigailhaddad

Description

@abigailhaddad

Fourth in the series (#29, #30, #38). #38 was about replicating the budget-endpoint blog posts; this one is from building budget-execution analysis on top of it — two concrete artifacts:

  1. An OpenOMB → Tango join: take an account OMB has restricted in an apportionment (OpenOMB surfaces the footnote) and pull the contractors that account pays (Tango), on the shared federal_account_symbol.
  2. An obligation-pace monitor: sweep contract-heavy discretionary accounts and flag any whose cumulative obligations are running materially behind their own prior-year pace at the same fiscal quarter (the test that caught ED Student Aid 091-0202 at −36% vs FY2025).

tango-python v1.1.1, a few hundred calls. None of this blocks the work — workarounds exist — but these are the things that made budget-execution analysis (as opposed to point lookups) harder than it needed to be. The items here are new; they don't overlap #38.


What worked well for this use case

  • federal_account_symbol ↔ OpenOMB Agency+Account is a clean join key. This is the entire reason the OpenOMB integration was trivial — OpenOMB has the apportionment footnotes Tango doesn't carry, Tango has the award layer OpenOMB doesn't, and they line up one-to-one on the account symbol. Please don't change that key.
  • quarters/ reproduces SF-133 by TAS, with the period of availability encoded in the TAS string (075-2025/2025-… one-year vs 075-X-… no-year). That made an obligation-pace comparison possible at all, and let us classify expiring vs. carryover money without a separate source.
  • Hydrated contracts on recipients/ (piid, description, total_contract_value, obligated) let us say concretely what an account buys, not just who it pays.

Friction (ranked by impact on this use case)

1. 🔴 recipients/ is contracts-only — no grant/assistance recipients

recipients/ returns only contract awardees. There is no equivalent for assistance: assistance_obligated exists at the account level, but no recipient breakdown hangs off it, and list_grants returns grants.gov opportunities (e.g. {"opportunity_number":"RDRUS-26-RFP","status":"Posted"}), not awarded grantees by account.

Why it bit: in our OpenOMB work, 168 of 169 spend-plan-restricted FY2026 accounts are grant/benefit programs (child nutrition, refugee assistance, foster care, SSI, …). For all of them the recipient drill-down returns nothing — exactly the accounts where "who gets this money" matters most, and the answer is "states and grantees" that Tango can't name here.

Ask: an assistance-recipients surface (funding-office × grantee, like the contract one), or — at minimum — document that recipients/ covers contracts only so users don't read an empty result as "no recipients."

2. 🔴 No batch / time-series access to quarterly obligations

quarters/ is per-account-per-year only (accounts/{id}/quarters/), and id is per (account, fiscal_year) (#38 item 5). So any cohort or longitudinal question — "which accounts are obligating behind prior years?" — is N accounts × M years of drill-down calls, each preceded by an id resolution.

Repro (our monitor): 69 candidate accounts × 3 fiscal years ⇒ a 3-year accounts/ sweep for ids plus ~200 quarters/ calls, and with the burst rate limit (Rate limit exceeded for burst. Please try again in 47 seconds.) the full scan runs ~15 minutes. Caching prior years (which don't change) helps reruns but not the first pass.

Ask (highest-impact item here): either expose a pace field on the accounts/ list response — cumulative-obligated-by-quarter, or a derived obligated_same_quarter_prior_year — so cohort pace analysis is one paginated sweep instead of hundreds of drill-downs; or a batch/filterable quarters endpoint (e.g. quarters/?fiscal_year=&federal_account_symbol__in=). This is the single biggest blocker for budget-execution monitoring.

3. 🟠 quarters/ open-year forward-fill has no "as-of" marker — and it caused a real bug

In the open year, quarters/ forward-fills future quarters with the latest actual (Q3==Q4==latest real, noted in #38). But there's no flag saying which quarter is the last actual one, and a genuinely flat quarter (zero obligations that quarter) is indistinguishable from "not yet reported."

Consequence: our first pass detected the latest-real quarter per account by finding where cumulative stops increasing — and that silently picked Q1 for some accounts and Q2 for others, producing nonsense pace ratios (e.g. 63,000%) before we caught it and pinned all accounts to one global as-of quarter.

Ask: an explicit as_of_quarter (or is_actual per row) on quarters/, ideally tied to the File A/B reporting period. One field removes the guesswork and the failure mode.

4. 🟡 get_contract carries no period-of-performance / end dates

get_contract returns piid, description, award_date, total_contract_value, naics_code, psc_code, obligated. Every shape request for performance dates is rejected:

client.get_contract(KEY, shape="piid,total_contract_value,period_of_performance(current_end_date,potential_end_date)")
# TangoValidationError: Invalid request parameters: Invalid shape

So you can't tell whether a contract is expiring — which blocks "about to recompete / about to end" analysis, the natural pairing with the appropriated-but-unobligated "pipeline" framing in the blog posts. (We could see what the account buys and how much is obligated, but not until when.)

Ask: expose period-of-performance start / current-end / potential-end on the contract shape.

5. 🟡 budget-flows is funding-office × account × year grain — duplicate years on naive aggregation

Reversing a vendor (get_entity_budget_flows), the same (federal_account_symbol, fiscal_year) repeats once per funding office, so a naive "obligated by year" sum double-handles years (we saw a vendor's FY2025 appear twice with different amounts before grouping by office). Not wrong, but a doc note on the grain — or an optional account×year aggregated view — would save the surprise.


Wishlist (in priority order)

  1. A pace field on accounts/ (or a batch quarters endpoint) so obligation-pace cohort analysis isn't hundreds of calls (item 2).
  2. An assistance-recipients surface, or docs that recipients/ is contracts-only (item 1).
  3. as_of_quarter / is_actual on quarters/ (item 3).
  4. Period-of-performance fields on the contract shape (item 4).

What Tango made possible here

The budget-execution monitor — flagging accounts obligating behind their own prior-year pace — is a genuinely useful thing to watch, and it came out of quarters/ + the stable account-symbol join with no other source needed for the pace itself. The asks above are about doing it at the scale of "all buying accounts, every quarter" rather than one account at a time.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdocumentationImprovements or additions to documentation

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions