Skip to content

Feedback from building a pocket-rescission watchlist on the new budget endpoint #38

@abigailhaddad

Description

@abigailhaddad

Third in the series (#29,
#30). This round is from kicking the
tires on the new budget endpoint (/api/budget/, Beta, announced June 1) — first
replicating all four "Build With Tango" posts, then building an actual pocket-rescission
watchlist (discretionary accounts with appropriated-but-unobligated money, split into
expiring vs. safe via the period of availability).

~150 calls across accounts/, accounts/{id}/recipients/, accounts/{id}/quarters/, and
entities/{uei}/budget-flows/, on tango-python v1.1.1. The SDK wraps all four budget
surfaces (list_budget_accounts, get_budget_account, get_budget_account_recipients,
get_budget_account_quarters, get_entity_budget_flows) and the blog-post code runs as
written — see the correction note below; an earlier draft of this issue wrongly claimed it
didn't, because we were on a stale local install.

None of the items below block the work — we have workarounds for everything — but flagging
them while it's fresh.


What's working great

  • The data is accurate. Every figure cited across all four blog posts replicates
    exactly against the live API — HHS contract-share table, the NASA 080-0124 primes
    ($1.12B / $623M / $483M), Boeing's budget-flows series ($1.04B FY20 → $1.35B FY22), BARDA's
    $567.9M apportioned-minus-obligated gap, VA 036-0110's $3.84B unobligated. We tried to
    break the numbers and couldn't. (verify_blog_posts.py checks all of them.)
  • shape= carries over from the rest of Tango unchanged, including nested
    (recipient.legal_business_name) — payloads stay small.
  • The inverse budget-flows/ surface is the standout. One UEI → full federal-account
    funding history by year is a join we'd otherwise stitch from FPDS by hand. Worked first try.
  • quarters/ encodes period of availability in the TAS string075-2025/2025-1000
    (one-year), 075-2025/2026-1000 (multi-year), 075-X-1000 (no-year) — and bundles every
    active budget-year tranche under the account. Once parsed, it's a clean lapse-risk
    classifier, and it rescued our analysis when bea_category turned out unreliable (item 4).
  • Pre-computed contract_share_of_obligated_capped makes the "is this account even
    contractable" triage a one-field sort, as advertised.

Friction we hit (ranked by impact)

1. ✅ None of the blog-post code runs on the shipped SDK — RETRACTED (our error)

Correction. An earlier version of this issue claimed the blog methods didn't exist in
the SDK. That was wrong: we were on a stale v0.4.1 local install. tango-python v1.1.1
(released May 29, before the June 1 announcement) ships list_budget_accounts,
get_budget_account, get_budget_account_recipients, get_budget_account_quarters, and
get_entity_budget_flows, and the blog-post code runs verbatim after
pip install --upgrade tango-python. Apologies for the noise.

The only residual (very minor) suggestion: the posts don't state a minimum SDK version, so a
reader on an older pinned tango-python hits AttributeError. A one-line "requires
tango-python >= 1.0" note on the posts would prevent that. Everything below is API-level and
independent of SDK version.

2. 🔴 bea_category is inconsistent across fiscal years

The first filter anyone reaches for ("show me discretionary accounts") is unreliable.

Repro:

for fy in (2024, 2025, 2026):
    r = client.list_budget_accounts(federal_account_symbol="075-0512",
            fiscal_year=fy, shape="bea_category", limit=1).results[0]
    print(fy, r["bea_category"])

Actual:

2024 Discretionary
2025 Discretionary
2026 Mandatory

Grants to States for Medicaid is mandatory — FY2026 looks corrected, FY2024/25 are wrong.
It's not a one-off; the whole distribution shifts year-to-year, and ~⅓ of accounts are null:

FY Discretionary Mandatory Net interest null
2025 1178 359 7 791
2026 1029 481 7 780

So a bea_category=Discretionary filter silently pulls $2T of mandatory programs into a
"discretionary" analysis in the older years. We stopped trusting the field and classified
expiring money from the quarters/ TAS instead.

Possible fix: reconcile bea_category across years (apply the FY2026 classification
back through the series), and document the null cases.

3. 🔴 Unsupported params are handled two different (both surprising) ways

Repro:

client.list_budget_accounts(fiscal_year=2024, totally_fake_field__gte=5)  # -> 200, count unchanged (2332)
client.get_account_quarters(account_id, fiscal_year=2026)                 # -> 404 Resource not found

On accounts/, an unknown/typo'd param is silently ignored and you get the full
unfiltered set back with a 200 — you can't tell "filtered to everything" from "filter did
nothing." On quarters/, an unsupported param 404s, which reads as "this account
doesn't exist" when the account is fine.

Possible fix: reject unknown params with a 400 that names them (and ideally echo the
applied filters in the response envelope).

4. 🟠 Filter operators are partially implemented, silently

On obligated_total (FY2024, unfiltered count = 2,332):

Filter count works?
obligated_total__gte=2e8 699
obligated_total__lte=2e8 1,633
obligated_total__gt=2e8 2,332 ❌ ignored
obligated_total__lt=2e8 2,332 ❌ ignored
obligated_total__isnull=true 2,332 ❌ ignored

__gte/__lte work; __gt/__lt/__isnull are accepted but do nothing. This cost real
time — we concluded "numeric filtering is broken," then found __gte works fine. Combined
with #3, a filter returning 200 tells you nothing about whether it applied.

Possible fix: implement the missing operators, or document the supported set per field.

5. 🟠 id is per (account, fiscal_year), not a stable account key

BARDA 075-1000 is id=20557 in FY2024 but id=19310 in FY2026. So every drill-down
(recipients/, quarters/) is year-bound and you must re-resolve the id for each year.
Surprising for something billed as "federal-account-grain" — callers assume the id
identifies the account, not the account-year.

Possible fix: make the id stable and take fiscal_year as a param on the sub-resources,
or rename it account_year_id so the binding is obvious.

6. 🟠 Descending ordering sorts NULLs first

Repro: list_budget_accounts(fiscal_year=2024, ordering="-contract_obligated") returns
rows with contract_obligated=null (Gifts & Donations, Congressional Publishing…) at the
top, burying the actual top contract accounts. You have to add contract_obligated__gte=1 to
push them out — which only works because __gte happens to be one of the implemented
operators (#4).

Possible fix: NULLS LAST on descending sorts.

7. 🟡 quarters/ forward-fills future quarters in the open year

For an FY2026 account today (we're in FY26 Q3), Q3 and Q4 cumulative both equal the last
actual value (Q2) — so Q4 shows a number that isn't real yet. Consumers have to detect
"latest quarter whose cumulative changed" to avoid overstating progress.

Possible fix: an is_actual / as_of_quarter flag on each row.

8. 🟡 quarters/ has obligated-cumulative per TAS but no per-TAS budget authority

You can see how fast each TAS is obligating but not its unobligated balance — so you
can't compute the dollar amount of one-year money actually at risk of lapsing. The only
unobligated magnitude is account-grain unobligated_balance, which isn't split by
availability period. Adding per-TAS BA (or unobligated) to quarters/ would make the
lapse-risk number directly computable instead of approximable from obligation pace.

9. 🟡 recipients/ mixes a null-key aggregate row in with real recipients

The top row of NASA 080-0124's recipients/ isn't Boeing — it's an unattributed bucket
(recipient_id=null, funding_office=null) of $2.1B / 128 contracts, larger than every
named prime. Post 2's example silently skips it; a naive
f"{r['recipient']['legal_business_name']}" crashes on the None (we hit exactly this).

Possible fix: an is_unattributed flag, or split the aggregate into its own field, and a
sentence in post 2 explaining what that bucket is.

10. 🟡 Capped ratios & unobligated_balance mislead on trust/financing accounts

obligated_to_apportioned_pct_capped caps at 1.0, hiding that trust funds obligate far more
than they apportion (075-8004: $23B apportioned, $278B obligated → ratio shows 1.0). And
unobligated_balance ≠ apportioned − obligated for those accounts (it includes carryover);
the two only coincide for clean annual discretionary money. Worth documenting which
definition unobligated_balance uses.

11. 🟢 Minor: cross-surface account_title mismatch + allocation-TAS parsing

  • Same symbol, two titles: 080-0124 is "Deep Space Exploration Systems" in accounts/ but
    "Exploration" in budget-flows/. A reader joining the surfaces on
    federal_account_symbol sees mismatched names.
  • Allocation/transfer TAS carry a 3-part prefix (069-017-2022/2022-1804) that breaks a
    naive split('-')[1] when parsing availability. Easy to handle once you know — worth a
    doc note.

Wishlist

  • Per-TAS budget authority on quarters/ (item 8) — turns lapse-risk from an
    approximation into a direct calculation. Biggest single win for this use case.
  • A stable account id + fiscal_year param on sub-resources (item 5).
  • Reconciled bea_category (item 2) — or, if it's genuinely year-specific, a doc note
    saying so and why.
  • 400-on-unknown-param across all surfaces (item 3) — would have saved us the most time.

What we built

A pocket-rescission watchlist — discretionary accounts carrying appropriated-but-unobligated
money, split into expiring (one-year) vs. safe (no-year), late in the fiscal year. It came
out of just two surfaces:

  1. accounts/ for the ranking: pull a fiscal year, take apportioned − obligated_total
    as the unobligated headroom, sort descending. One paginated call set.
  2. quarters/ for the lapse classification: parse the period of availability out of each
    TAS string (075-2025/2025-1000 = one-year, …/2026… = multi-year, 075-X-… = no-year),
    and bucket the obligation activity. One call per candidate account.

The second step is also what let us route around the bea_category problem (#2): instead of
trusting the category label, we keep accounts whose obligation is mostly in one-year TAS.
That cleanly drops the mandatory/financing accounts that leak through —

075-0512 Medicaid          one-year share   1%   -> dropped
086-0236 FHA financing     one-year share   0%   -> dropped
024-8424 FEGLI life-ins.   one-year share   0%   -> dropped
017-1804 Navy O&M          one-year share  99%   -> kept

— and leaves a watchlist of genuine annual money (DoD O&M, Military Personnel, Defense Health
Program, etc.). The whole thing is ~250 lines against the two endpoints; the hardest part was
the workarounds above, not the analysis. Once those were understood it was straightforward.

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