Skip to main content

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.
  • **isthepricemoveappliedtoTODAY′sposition∗∗(today′ssharecount×pricemove).Buys,sells,anddeposits(flows)are∗∗excluded∗∗—buyingis the price move applied to TODAY's position** (today's share count × price move). Buys, sells, and deposits (flows) are **excluded** — buying10K 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

Rules:
  1. Hooks fetch, the engine compares. A data hook may know table quirks (SnapTrade’s snapshot currency_code lies; manual prices are feed-units); it expresses them as engine INPUTS. It never subtracts two values itself.
  2. One baseline policy via baselineSnapshotIndex, everywhere.
  3. Trust gate over silent partials. An aggregate missing value lies in the quiet direction. TWO conditions, because the first has a blind spot: unmatchedCount === 0 across 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 4.68Mofa4.68M of a 6.69M basket, because ~2Msatininvestmentaccountsholdingabalancewithnoper−holdingrows.Prod−wide,1,164usershold2M sat in investment accounts holding a balance with no per-holding rows. Prod-wide, 1,164 users hold 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_code columns, 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 === 0 or 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 baselineQuantity is absent rather than guessing, and its 25% value-continuity window is a deliberate trade: it misses a split with a
    25% 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.
  • periodChanges routed 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 − frozenAggregate across 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 “−3,948(−74.06areindifferentunitsystems;theenginemadesplit−dayDOLLARSworsethanthevalue−diffitreplaced(repricingtoday′s4×sharecountatthepre−splitprice→afake−3,948 (−74.06%)" in the movers dialog. Prices either side of a split are in different unit systems; the engine made split-day DOLLARS worse than the value-diff it replaced (repricing today's 4× share count at the pre-split price → a fake −29.6K on a $10.2K position), and the trust gate could not see it because the comparison “succeeds”. Fixed by splitAdjustedBaselinePrice; needs baselineQuantity on the input.
  • Process note: Phase 1 first merged into an already-merged branch and reached no released ref. A PR whose base is neither main nor staging gets 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.
  1. Phase 2 — trust-gated dashboard card (CFO-6403 close-out). ✅ DONE. useInvestmentsDayChange sums all four legs and the card uses it when the period is 1D (the default) and every leg reports unmatchedCount === 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 own baselineValueUsd, 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.
  2. Crypto family convergence. ✅ COMPLETE. use-coinbase-daily-snapshots deleted (dead code — Coinbase was already inside the crypto table); use-crypto-daily-snapshots and use-plaid-crypto-holdings both 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.
  3. 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.
  4. Weekly changes ride the engine with a different baseline date.
  5. Period-baseline unit test: the only test touching periodChanges mocks the hook, so the suite doesn’t validate that path.