Display currency
Added 2026-10-01 (A user picks a display currency in Settings → General → Display currency. Every money value on the migrated screens is then shown in that currency, converted from USD. Read this before printing a money value in the app. Then readfeat/display-currency-v01). Behind the PostHog flagdisplay-currency-v01.
docs/VALUATION-SURFACES.md before touching how anything is valued.
The one principle: convert at the edge
Nothing about storage or valuation changes. Every surface indocs/VALUATION-SURFACES.md keeps computing and storing USD: the snapshot cron,
the DB views, the net-worth RPCs and getFinancialMetrics. The display
currency is applied only when a number is printed:
shown = amount_usd × rate(USD → display currency, for the right day)
rate follows exchange_rates.rate’s own direction, display units per 1 USD
(EUR 0.89 means 1 USD = €0.89).
Currencies
lib/display-currency/currencies.ts lists USD, AUD, GBP, CAD, EUR, INR, NOK,
PHP, CHF, BTC and SATS: each fiat one was asked for in a CFO Linear request
(the file lists them). BRL was in the first test list and was dropped because
nobody had requested it.
The short list is deliberate while testing (Felipe, 2026-10-01).
- Every code has a live row in
exchange_ratesand daily history inexchange_rates_historicalfrom 2026-03-04. - Both tables are written by the Silvia-Admin FX job, every 4 hours, from Finnhub. That job also derives BTC and SATS from the BTC price.
- Fiat currencies print through
Intl.NumberFormat('en-US', { currency }):€1,234,£1,234,CA$1,234,A$1,234,₹1,234,₱1,234. CHF and NOK have no English symbol, so they print asCHF 1,234andNOK 1,234. Grouping stays English for every currency, including INR (₹1,234,567, not lakh-style). - BTC (
₿0.0118) and SATS (1,180,000 sats) are formatted by hand, becauseIntlrejectsSATSand printsBTC 0.01.
The pieces
Rules for call sites
-
Only convert USD amounts. A
MoneyFormattertakes USD. Never pass it a native amount:- a transaction in its account’s currency
*_native/native_currency_codevaluesother_assets.value(usevalue_usd)- a non-USD card’s raw
credit_limit - QuickBooks book figures
-
Pure adapters take
money: MoneyFormatter = USD_MONEY(or amoney?field on their context object). Components passuseMoneyFormatter(). Putmoneyin every memo dependency list that uses it. -
With USD the formatter is byte-identical to the old helpers.
lib/display-currency/__tests__/format.test.tspinsformat≡formatCurrencyString,signed≡formatSignedCurrencyandprice≡formatSharePrice. That is why migrated screens ship unflagged. -
Amounts you type stay USD. Input fields are not converted. The Settings
row says so. Entering a manual asset in its own currency is Phase 5b of the
plan (the
other_assetscurrency +value_usdpattern).
Long figures fit their boxes
A converted figure is longer than its dollar original:$22,537,105 becomes
CA$22,537,105, CHF 18,669,834 or 26,595,000,000 sats. Boxes sized for
dollars overflowed: the dashboard cards ran past their edge (Felipe,
2026-10-05), and a sweep of the converted screens, checked on the preview in
sats, found the same in the tables, the Allocation card and the sector
donut. So every money figure in a box that does not grow with its text is
fitted:
components/fit-text.tsx sets the figure’s font size from its box’s width
and the text’s measured Figtree width (lib/fit-text.ts, weights 300 to
700). Nothing is measured in JavaScript, so there is no flash and the server
markup matches. A figure that fits keeps its usual size. Only one that would
overflow shrinks, down to a floor. Verified in Chromium against the served
fonts: 1,537 box, size and figure combinations, no overflow.
- Only converted figures are fitted. The call sites pass
enabled={!money.isUsd}, so a dollar figure renders exactly the markup it had. Dollars are all a flag-off user ever sees. This also leaves an existing dollar overflow (a 10-digit net worth on a narrow card) as it was. Drop the gate at GA to fix that too. - Figtree lacks
₱and₿. The browser borrows them from a system font whose width varies by device and weight, so they are budgeted at 0.75em (0.9em at 600 and up), wider than any rendering measured. A peso or bitcoin figure may shrink a hair early, but it never overflows. Any character missing from the table counts as 0.8em for the same reason. - A currency output gains a new character (a symbol, a suffix): measure it
in Chromium against the served Figtree files and add it to
lib/fit-text.ts. Until then it uses the 0.8em fallback, which is safe but shrinks early. - A new money figure in a fixed box: wrap it in
FitTextthe same way, at the weight it renders in. A size container takes no width from its content, so the box must get its width from the layout (a block, a grid cell, aflex-1item, a fixedw-*), never from the text:- the default
fit='parent'adds a block wrapper that measures its parent fit='ancestor'measures a container you set yourself ([container-type:inline-size]on a fixed-width column) and keeps siblings on the line, withreservePxfor their width- in a content-sized parent, use
fit='parent-below-sm', wrap the row (as the side cards do), or don’t fit
- the default
History and change include FX
Decision 1 (Felipe, 2026-10-01): a euro reader’s wealth moves when the euro moves.- Chart series convert point by point at each day’s rate.
useDetailChartSeriesdoes this itself, so itspointsanddollarChangecome back in the DISPLAY currency. Print them withmoney.formatConverted/money.signedConverted;money.formatwould convert twice. Lines that skip that hook (cost basis, hover series) useuseConvertedSeries. - Period changes are
current × rateNow − start × rateThen, notconvert(usdChange)(fxInclusiveChange). The detail headers useuseDisplayPeriodChange, with the rate day fromperiodStartIso. The dashboard cards restate theirs withperiodBaselineDatefromuseDailySnapshots. A flat USD month can therefore read as a gain in euros, so arrows, colours and signs follow the restated number. - Per-holding rows stay price-only. Their 1D/period change is the USD move at today’s rate, so rows need not sum to an FX-inclusive header. Cost basis and gains also convert at today’s rate (decision 2), so a gain’s percentage equals the USD one.
- Before 2026-03-04 there is no FX history. Older days use the earliest
stored rate (decision 3, “use earliest rate + backfill later”). Backfilling
exchange_rates_historicalin Silvia-Admin makes those days exact with no code change. - Today always uses the live rate, even after today’s history row lands, so a chart’s right-hand point equals the header.
requestHistory). So chart components need no Clerk or React Query wrapper in
tests.
Fail-safes
Every failure resolves to USD, never to a silent 1:1 under a foreign symbol:- flag off → USD (the saved preference is kept, which makes the flag a lossless kill switch)
- preference USD → USD, and no rate request
- rate loading → USD for that moment
- no rate / read failed → USD; the Settings row says so
- unrecognised stored code → USD (prod held a stray
CNY)
Rollout (display-currency-v01)
- Non-production builds are on by default;
?display_currency=controlshows the flag-off app. - Production fails closed.
- Ladder: internal cohort → a small group of non-USD users (CAD first) → a percentage ramp → 100% → remove the flag.
- Adoption event:
display_currency_changed(from,to). - Known limit: flags resolve after first paint, so a flagged EUR user briefly sees USD on load. Bootstrap the flag, or skeleton the money, before real-user steps.
What is NOT converted yet
Period changes: the matched basket and the baseline day
Three rules keep a page’s header change and its chart caption equal, and right, in every currency (pinned bycontexts/__tests__/display-currency-context.test.tsx):
- Restate from the day the change was measured from. The change hooks
resolve it (
useHoldingsDailySnapshots().periodBaselineDate,useCryptoPeriodChanges().baselineDate; for “All time”, the user’s first snapshot day). Pass it touseDisplayPeriodChangeand touseDetailChartSeries’changeBaselineDay. Without it, the calendar baseline (periodRateDay) is used. - Only matched positions move with the currency. A position opened inside
the window has no baseline, so its USD change is 0 (CFO-6403); its currency
move must be 0 too. Pages pass
matchedCurrentUsd(today’s value of the positions with a baseline:matchedMarketValue/matchedCryptoValue) andpreviousUsd(their percentage base) in aUsdPeriodChange. - Each position at its own baseline day. A period change reads each
position’s EARLIEST snapshot inside a 7-day window from the requested day:
per source for holdings, per holding for crypto. A snapshot gap or a
position first seen inside that week puts its real baseline a few days
later, so the hooks also report the day per position
(
periodBaselineDays/baselineDays). The pages split their matched positions by that day (matchedChangeByBaselineDay,aggregateChange(…, { days, fallbackDay })) intoUsdPeriodChange.byBaselineDay.restatePeriodChange, which the header and the caption share, moves each share at its own day’s rate. With one day it is exactlyfxInclusiveChange(CodeRabbit on #4601).