Day change — how “how much did it move today” works
The single engine behind every day-change number in the app. If you are touching anything that diffs a live value against a snapshot, read this first; if a surface computes its own diff, that is either listed in the adoption table below as unconverged, or it is a bug.Semantics (what the numbers mean)
Deliberate and uniform across surfaces:- % is the price move. Per position, the ratio of today’s price to the baseline price.
- **10K of stock is not ”+$10K today”. “Change today” answers how did my holdings perform, not how did my total move.
- FX day-moves are excluded. Where a source offers native prices, the change is a native-price ratio (currency cancels); where only USD exists (manual holdings), the USD pair under CFO-5666 conversion is equivalent under these semantics.
- After hours, 1D is the full session — today’s close vs yesterday’s close. During market hours it is live vs yesterday’s close (today’s move so far). Same definition as every brokerage.
- Aggregates are sums of per-position results — never
liveTotal − frozenAggregate(the CFO-6403 mechanism). A surface that cannot enumerate positions does not get an engine day-change number.
Architecture
- Hooks fetch, the engine compares. A data hook may know table quirks
(SnapTrade’s snapshot
currency_codelies; manual prices are feed-units); it expresses them as engine INPUTS. It never subtracts two values itself. - One baseline policy via
baselineSnapshotIndex, everywhere. - Trust gate over silent partials. An aggregate missing value lies in
the quiet direction. TWO conditions, because the first has a blind spot:
unmatchedCount === 0across every leg (positions the engine tried and failed to compare) AND the summed baseline covers the card’s live basket within 5% (value that produces no positions at all, which no per-position count can see). Staging QA hit exactly that: every leg reported 0 unmatched while the engine explained 6.69M basket, because ~244M in such accounts.
The baseline policy
Baseline = the newest snapshot dated strictly before the MARKET day (America/New_York) still in progress. (baselineSnapshotIndex(dates, marketTodayKey()), dates newest-first.)
Why the market’s calendar and not UTC — the evening-zero incident
(2026-08-06, prod): the snapshot cron fires at 00:00 UTC (8 PM EDT / 7 PM
EST), stamping rows with the day that just ended but capturing tonight’s
closes. A UTC “today” has already rolled to tomorrow at that hour, so the
seconds-old snapshot passed the “isn’t dated today” test and became the
baseline — live minus itself, “$0 / 0.00%” on every row, every evening until
the next session’s first tick. In the market’s own calendar that snapshot is
dated today, gets skipped, and the baseline falls to yesterday’s close: the
evening shows the full-session change, and mornings are unchanged. Non-US
venues ride along — their sessions close before New York’s day ends.
Two hardenings that came with it:
- “Strictly before today” (not ”≠ today”), so a future-dated stray row can never become a baseline.
- The key is reactive (
useMarketTodayKey, minute-tick, updates only on actual change) — read once inside a memo it went stale at midnight and a mounted dashboard kept the day-before baseline.
useDailySnapshots (summary rows, one per user-day) additionally fetches a
3-day buffer so a pre-today baseline exists even for a 1-day window, whose
only in-window row can be tonight’s capture. The buffer lives inside
fetchDailySnapshots because prefetch-dashboard shares the query key;
consumers still receive exactly the window they asked for.
Where it’s used (adoption map)
Why we do what we do (decision log)
- Ratio over convert-then-diff (from #2408): both prices native → the
ratio cancels currency. No rate table, immune to lying
currency_codecolumns, and FX movement between snapshot-time and now can’t leak in. - Flows excluded from $ change: the old per-position code partially included flows (baseline used the OLD share count); the engine uses today’s.
- Trust gate over silent partials:
unmatchedCount === 0or fallback. - Split detection over split data: the guard infers a split from the
quantity/price signature rather than requiring a corporate-actions feed,
because the signature is already in the data we have. It declines when
baselineQuantityis absent rather than guessing, and its 25% value-continuity window is a deliberate trade: it misses a split with a25% same-day move (visibly wrong for one day) to avoid reinterpreting real crashes as splits.
- Market-day baseline over UTC: see the evening-zero incident above.
periodChangesrouted through the engine (#3096 merge with #3086) rather than preserving the old arithmetic for one caller — which would have re-created the duplicate-arithmetic disease one week after writing it down. Consequence, deliberately accepted: period pills (1W/1M/6M/YTD) on the detail pages exclude flows, like 1D — a visible change to numbers #3086 shipped, and the more correct one.- Every surface, one baseline: every “1D change” bug shipped — CFO-6403’s +$35K phantom, the −8.78% chart vs +1.93% header incident, the app-wide two-day “today”, the evening zeros — was the same disease: two pieces of code computing “change” from two different reads of the same money.
Incident index
- CFO-6403 — dashboard card +$35K phantom:
liveTotal − frozenAggregateacross different baskets/capture moments. - 2026-07-28 — detail page showed −8.78% chart vs +1.93% header on one screen (different reads of one quantity).
- Two-day “today” — three hooks hardcoded
dates[1]as the baseline, silently comparing against two days ago. - 2026-08-06 evening zeros — UTC todayKey vs the 00:00 UTC cron; see the baseline section.
- CFO-6511 splits — a 4:1 CRWD split on 2026-07-02 rendered as
“−29.6K on a $10.2K position), and the trust gate
could not see it because the comparison “succeeds”. Fixed by
splitAdjustedBaselinePrice; needsbaselineQuantityon the input. - Process note: Phase 1 first merged into an already-merged branch and
reached no released ref. A PR whose base is neither
mainnorstaginggets no CodeRabbit review, and a stacked PR whose base merges first is not always retargeted.
Backlog (what still needs to happen)
Registry only — the sections above are the source of truth for what IS.Phase 2 — trust-gated dashboard card (CFO-6403 close-out).✅ DONE.useInvestmentsDayChangesums all four legs and the card uses it when the period is 1D (the default) and every leg reportsunmatchedCount === 0. Measured before shipping: 6,699 of 10,764 users with holdings (62%) clear the gate; of those, 99.9% of engine numbers are a plausible daily move (≤15% of basket) while 3.3% of the legacy numbers they replace are not — the remaining 38% keep the legacy diff, which is the gate doing its job. Combination is weighted by each leg’s ownbaselineValueUsd, never an average of the four percentages. Remaining: remove the legacy fallback once validated against the CFO-6403 reporter’s account, and extend beyond 1D if per-period decomposition is ever built.Crypto family convergence.✅ COMPLETE.use-coinbase-daily-snapshotsdeleted (dead code — Coinbase was already inside the crypto table);use-crypto-daily-snapshotsanduse-plaid-crypto-holdingsboth on the engine + market-day baseline. Every leg in the card’s basket now converged. Audit findings recorded: the crypto hook’s % was already a fraction (the ×100 bug was Coinbase-only); its +1000%/−100% clamp is removed — measured over 70,927 prod pairs the −1 clamp fired 0 times and +10 fired once, on a +5579% data artifact. New prerequisite discovered via CFO-6511: stock splits produce a fake ±70% row (and a worse dollar than the old value-diff) on split day — add the split guard before the card flip.- Chat/voice/briefs: compute the change server-side in the tool response, or add prompt guidance on which rows to diff — today the model picks rows itself and answers can vary.
- Weekly changes ride the engine with a different baseline date.
- Period-baseline unit test: the only test touching
periodChangesmocks the hook, so the suite doesn’t validate that path.