[WSAPI-11] Add total investments-balance endpoint (cash + priced positions) #9

Merged
chris merged 10 commits from feature/WSAPI-11 into main 2026-09-21 10:17:00 -06:00
Owner

Ticket

WSAPI-11 Add a total investments-balance endpoint (cash + priced positions)

Summary

  • Adds GET /balance, serving a cached total investments balance — cash plus priced non-cash holdings, per account and grand — computed nightly by sync/balances from lib/wealthsimple/accounts + lib/wealthsimple/balances + lib/wealthsimple/securities's fetchSecurityQuotes() (added in WSAPI-10, and exercised against real data by this ticket for the first time).
  • No FX conversion, by design: Wealthsimple's API has no currency conversion, so every total in this feature — per-account and grand — is a { cad, usd } object mirroring the existing cash shape, rather than a single (and wrong) blended number.
  • requestedQuotes/pricedQuotes ride alongside every total, cached and served from /balance, so a consumer can tell a fully-priced total apart from one degraded to cash-only (or partially unpriced) after a securities-fetch failure — that failure is isolated to its own try/catch so it no longer makes /positions//accounts go stale too, but without this signal it would otherwise look identical to a healthy sync (ok: true, fresh syncedAt, GET /status success).
  • A SessionDeadError raised mid-quotes-fetch still halts the whole sync rather than being swallowed by that isolation, preserving the existing "never retry authentication in a loop" contract.
  • All currency totals are rounded to the cent at each summation boundary to avoid floating-point drift, and quote currency comparison is case/whitespace-normalized since it's an unverified free string from Wealthsimple.
  • Full test coverage across lib/wealthsimple/balances, lib/account-balances-cache, sync/balances, and routes/balance, plus doc updates to both CLAUDE.md files (and a final pass compressing the sections this ticket touched, since several review rounds had let them grow — see the last commit).
## Ticket [WSAPI-11](http://192.168.2.100:7123/home/browse/WSAPI-11/) Add a total investments-balance endpoint (cash + priced positions) ## Summary - Adds `GET /balance`, serving a cached total investments balance — cash plus priced non-cash holdings, per account and grand — computed nightly by `sync/balances` from `lib/wealthsimple/accounts` + `lib/wealthsimple/balances` + `lib/wealthsimple/securities`'s `fetchSecurityQuotes()` (added in WSAPI-10, and exercised against real data by this ticket for the first time). - **No FX conversion, by design**: Wealthsimple's API has no currency conversion, so every total in this feature — per-account and grand — is a `{ cad, usd }` object mirroring the existing cash shape, rather than a single (and wrong) blended number. - **`requestedQuotes`/`pricedQuotes`** ride alongside every total, cached and served from `/balance`, so a consumer can tell a fully-priced total apart from one degraded to cash-only (or partially unpriced) after a securities-fetch failure — that failure is isolated to its own try/catch so it no longer makes `/positions`/`/accounts` go stale too, but without this signal it would otherwise look identical to a healthy sync (`ok: true`, fresh `syncedAt`, `GET /status` success). - A `SessionDeadError` raised mid-quotes-fetch still halts the whole sync rather than being swallowed by that isolation, preserving the existing "never retry authentication in a loop" contract. - All currency totals are rounded to the cent at each summation boundary to avoid floating-point drift, and quote currency comparison is case/whitespace-normalized since it's an unverified free string from Wealthsimple. - Full test coverage across `lib/wealthsimple/balances`, `lib/account-balances-cache`, `sync/balances`, and `routes/balance`, plus doc updates to both `CLAUDE.md` files (and a final pass compressing the sections this ticket touched, since several review rounds had let them grow — see the last commit).
Adds holdingsValueFor/accountTotalFor/grandTotalFor and the CurrencyTotal/AccountTotal
types, pricing non-cash holdings via fetchSecurityQuotes and bucketing by currency with
no FX conversion, per account and grand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds an optional totals field to AccountBalancesCacheDocument and extends set() to write
it, tolerating a pre-ticket document that has accounts but no totals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Collects distinct held securityIds across every fetched account, batch-resolves them via
fetchSecurityQuotes, and computes/caches per-account and grand totals alongside the raw
balances. Zero accounts still short-circuits with no securities call; a securities-fetch
failure is caught by the existing try/catch like an accounts- or balances-fetch failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Serves the cached total investments balance (cash + priced holdings, currency-split),
never calling Wealthsimple live. Tolerates both an empty cache and a pre-ticket cache
document with accounts but no totals. Wired into create-app alongside the other routers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds routes/balance/ to the architecture tree and API Endpoints table in the root
CLAUDE.md, and updates lib/wealthsimple/CLAUDE.md's securities bullet to note
sync/balances now consumes fetchSecurityQuotes to compute and cache totals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Column widths need re-padding after the new GET /balance row; format:check was catching
whitespace only, no content change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
HIGH: isolate fetchSecurityQuotes in sync/balances behind its own try/catch — a securities-
fetch failure (the least-verified call in the pipeline) now degrades to cash-only totals
instead of aborting the whole sync and staling /positions and /accounts too.

MEDIUM:
- log.warn when priced quotes fall short of requested securityIds, and log the
  requested/priced counts on the success line too, so a degraded run is distinguishable
  from a healthy one in logs and doesn't look identical to genuine $0 holdings.
- normalize quote currency (trim + uppercase) before bucketing, since quoteV2.currency is
  an unverified free string and a casing variant must not silently drop a holding.
- fix routes/balance to default totals as one unit and only echo syncedAt when totals
  actually exists, so a legacy cache document can't read as "synced recently, genuinely $0".
- round every CurrencyTotal bucket to the cent at each summation boundary
  (holdingsValueFor, accountTotalFor, grandTotalFor) to stop floating-point drift from
  multiplying quantity * price.
- pin that the securities fetch happens once across accounts with a deduped, cash-free
  securityIds filter, not once per account.

LOW: extract BalanceTotals as a named type instead of writing the totals shape out three
times; alias CurrencyTotal to the identical CashBalance instead of duplicating it; add a
same-security-across-two-custodian-sleeves test to holdingsValueFor; collapse the
routes/balance seeded-cache test to one full-body toEqual; fix three stale doc references
(lib/wealthsimple/CLAUDE.md's balances bullet, and two "GET /positions and GET /accounts"
omissions of the new GET /balance) that the branch's own earlier doc commit missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MEDIUM (A): a securities-fetch failure was silently overwriting a prior fully-priced total
with a worse cash-only one, indistinguishable from a healthy sync (ok:true, fresh syncedAt,
GET /status success). Add requestedPositions/pricedPositions to BalanceTotals, populated
from the exact counts already computed for the finding-2 log line, cached, and served from
GET /balance (defaulted to 0 alongside the rest of the empty/legacy-cache fallback) — a
consumer can now tell a full sync apart from a degraded one.

LOW (B): update lib/account-balances-cache's set() header comment, which claimed there's
never a partial set to merge — now false for totals specifically, and now says how a reader
tells the difference (the new position counts).

LOW (C): the inner try/catch around fetchSecurityQuotes was unconditional and would also
swallow SessionDeadError, breaking the "halt until a human re-runs the bootstrap CLI"
contract lib/wealthsimple/client never lets that error be caught for. Re-throw it
immediately as the first line of the catch, before any degrade-and-continue handling.
Added a test pinning that a SessionDeadError raised mid-quotes-fetch still fails the whole
sync via the outer catch, using the same 401-then-invalid_grant technique
lib/wealthsimple/client/.test.ts already pins directly.

LOW (D): AccountTotal.cash was cashBalanceFor's raw, unrounded sum across custodian-account
sleeves, so a fractional two-sleeve split could leak a float tail into /balance the same way
finding 5 fixed for holdingsValue/total. Round it locally in accountTotalFor (not in
cashBalanceFor itself, which /accounts also calls and is out of scope).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Must-fix (missing test coverage):
- Add a fractional two-sleeve cash-split test to accountTotalFor, pinning finding D's
  rounding fix (0.1 + 0.2 leaks to 0.30000000000000004 without it).
- Strengthen the SessionDeadError-propagation test: it previously only asserted ok:false/
  lastResult:'failure', which would also pass if the sync failed at the very first call and
  never reached the quotes stage. Now also asserts accounts/balances each succeeded exactly
  once, the failure's error name is specifically SessionDeadError, and the cache is
  untouched.

Cheap polish:
- Rename requestedPositions/pricedPositions to requestedQuotes/pricedQuotes everywhere
  (type, sync, route, cache doc comments, all tests) to match the existing sibling log
  line's naming and this codebase's usual per-entry sense of "position" — cheap now, a
  breaking change later since these are public GET /balance response fields.
- Document requestedQuotes/pricedQuotes in CLAUDE.md's /balance endpoint-table row.
- Add a requestedQuotes:2/pricedQuotes:1 assertion to the existing partial-pricing test —
  the one place that actually exercises 0 < priced < requested, previously unchecked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Compress CLAUDE.md sections this ticket touched.
All checks were successful
server / check (pull_request) Successful in 27s
622c6b134b
Root CLAUDE.md: the /balance row in the API Endpoints table had grown to roughly 3x the
length of every other row across the review rounds; tightened to the same facts (shape,
currency split, the requestedQuotes/pricedQuotes degraded-total signal, never live) in
about 40% fewer characters.

lib/wealthsimple/CLAUDE.md: the securities bullet's closing sentence re-listed
holdingsValueFor/accountTotalFor/grandTotalFor, already spelled out in full one bullet
above, and narrated "the first real consumer... for the first time" as history rather than
a durable fact. Trimmed to point at the one function that actually reads a quote and state
the load-bearing part — the two assumptions stay unverified until that path runs live.

No load-bearing guidance removed; sections this ticket didn't touch are untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chris merged commit 4e0f4cf6ef into main 2026-09-21 10:17:00 -06:00
chris deleted branch feature/WSAPI-11 2026-09-21 10:17:00 -06:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
chris/wealthsimple-api!9
No description provided.