> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# DAY CHANGE

# 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.
* \*\*$is the price move applied to TODAY's position** (today's share count ×
  price move). Buys, sells, and deposits (flows) are **excluded** — buying$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

```
lib/day-change/
  core.ts       computeDayChanges(inputs) — ALL comparison arithmetic.
                Per position: native-price ratio when the source offers
                native prices; USD price-pair fallback otherwise. Returns
                per-key changes + a summed aggregate with a
                matched/unmatched trust gate.
  baseline.ts   baselineSnapshotIndex() + marketTodayKey() — THE baseline
                policy (pure, clock-injectable, unit-tested).
hooks/
  use-market-today-key.ts   marketTodayKey() as REACTIVE state, so a tab
                            open across New York midnight rolls forward.
```

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.68M of a $6.69M basket, because \~$2M 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)

| Surface | Hook | Status |
| - | - | - |
| My Portfolio rows, old + new Public Investments pages, dashboard Top/Bottom Movers | `useHoldingsDailySnapshots` | ✅ engine + market-day baseline |
| Dashboard metric cards (net worth / cash / debts), Wrapped | `useDailySnapshots` | ✅ market-day baseline (per-metric diffs stay — no per-position decomposition exists for these) |
| Dashboard **Investments & Crypto** card, 1D | `useInvestmentsDayChange` | ✅ engine — sums all four legs, trust-gated. Falls back to the legacy diff when the period is not 1D or any leg reports `unmatchedCount > 0` |
| Precious metals leg | `use-precious-metals-daily-snapshots` | ✅ engine + market-day baseline — built, **zero consumers wired** |
| Crypto — wallet / manual / **Coinbase** (prod page + new detail page) | `use-crypto-daily-snapshots` | ✅ engine + market-day baseline, all three periods (daily/weekly/earliest) |
| ~~Coinbase~~ | ~~`use-coinbase-daily-snapshots`~~ | 🗑️ **deleted — dead code, zero importers.** Never a separate leg: Coinbase holdings live in `snapshots_by_crypto_holding` (`source='coinbase'`, 47,100 rows / 884 users), which the crypto hook reads with no source filter. Both defects that made it look hard (compares VALUES so deposits counted as P\&L; % ×100 against the engine's fractions) only ever existed in code nothing rendered. |
| Plaid crypto | `use-plaid-crypto-holdings` | ✅ engine + market-day baseline. Convergence also fixed three latent defects: a USD-vs-native-currency compare (FX bug on any non-USD account), a closest-to-calendar-yesterday baseline (could pick a today-dated row — evening-zero class), and a key mismatch (maps keyed by `security_id` while the crypto table looks up `plaid-${id}`) that meant **Plaid 1D/1W columns had rendered 0 forever** |
| Chat / voice / portfolio briefs | raw snapshot rows handed to the model | ❌ no policy — the model subtracts whichever rows it picks |

## 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.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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.