Skip to main content

Display currency

Added 2026-10-01 (feat/display-currency-v01). Behind the PostHog flag display-currency-v01.
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 read 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 in docs/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_rates and daily history in exchange_rates_historical from 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 as CHF 1,234 and NOK 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, because Intl rejects SATS and prints BTC 0.01.

The pieces

Rules for call sites

  1. Only convert USD amounts. A MoneyFormatter takes USD. Never pass it a native amount:
    • a transaction in its account’s currency
    • *_native / native_currency_code values
    • other_assets.value (use value_usd)
    • a non-USD card’s raw credit_limit
    • QuickBooks book figures
    Multiplying a CAD amount by the USD→EUR rate is the bug this feature is most likely to produce. Activity feeds and transactions stay in each item’s own currency, like a bank statement.
  2. Pure adapters take money: MoneyFormatter = USD_MONEY (or a money? field on their context object). Components pass useMoneyFormatter(). Put money in every memo dependency list that uses it.
  3. With USD the formatter is byte-identical to the old helpers. lib/display-currency/__tests__/format.test.ts pins format ≡ formatCurrencyString, signed ≡ formatSignedCurrency and price ≡ formatSharePrice. That is why migrated screens ship unflagged.
  4. 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_assets currency + value_usd pattern).

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 FitText the 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, a flex-1 item, a fixed w-*), 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, with reservePx for their width
    • in a content-sized parent, use fit='parent-below-sm', wrap the row (as the side cards do), or don’t fit

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. useDetailChartSeries does this itself, so its points and dollarChange come back in the DISPLAY currency. Print them with money.formatConverted / money.signedConverted; money.format would convert twice. Lines that skip that hook (cost basis, hover series) use useConvertedSeries.
  • Period changes are current × rateNow − start × rateThen, not convert(usdChange) (fxInclusiveChange). The detail headers use useDisplayPeriodChange, with the rate day from periodStartIso. The dashboard cards restate theirs with periodBaselineDate from useDailySnapshots. 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_historical in 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.
History is fetched by the provider, once, only after a chart on screen asks (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=control shows 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 by contexts/__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 to useDisplayPeriodChange and to useDetailChartSeries’ 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) and previousUsd (their percentage base) in a UsdPeriodChange.
  • 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 })) into UsdPeriodChange.byBaselineDay. restatePeriodChange, which the header and the caption share, moves each share at its own day’s rate. With one day it is exactly fxInclusiveChange (CodeRabbit on #4601).