# USD Storage + Display-Currency Conversion — Design

Date: 2026-05-16
Status: Implemented
Supersedes: the "FX cache out of scope" note in
`2026-05-16-multi-market-symbols-design.md`.

## Core concept (firma 기본 컨셉)

1. **Storage is always USD.** Whatever currency the user enters, the value
   is converted to USD before it is stored. `transactions.price`,
   `prices.current_price`, `portfolio_snapshots`, `balance_entries`,
   `flow_entries` all hold USD; the `currency` column is always `'USD'`.
   The original input currency is not retained.
2. **Reads convert to the user-selected display currency.** `show *` /
   `report *` / MCP output render in the currency the user picked.

`balance` / `flow` already follow this (CLI `add balance` converts via
`toUSD`, stores `currency: 'USD'`; show paths convert back via
`usdToDisplayAtDate`). This change brings `transactions` and `prices` —
and therefore `portfolio_snapshots` — onto the same model.

## Problem

The multi-market feature lets users record non-US holdings, but a KRX
transaction stores `price` as a raw KRW number with `currency: 'KRW'`,
and a synced KRX `prices.current_price` is raw KRW from Yahoo.
`buildHoldings` and `show portfolio` treat every number as USD — so a
mixed US+KRX portfolio sums KRW into the USD total. There is no
conversion at write time *or* read time.

## Decisions

### Write side — convert to USD at entry

- **`add txn` (CLI) / `add_txn` `edit_txn` `add_txns` (MCP)**: collect the
  price (and cost basis / dividend / tax amounts) in the market's native
  currency, convert to USD using the **transaction-date** FX rate, store
  `price` in USD and `currency: 'US D'`. Trade-date rate (not today's) so
  cost basis reflects the rate when the trade happened.
- **`sync` prices**: Yahoo returns `current_price` in the market's native
  currency (`quote.currency`). Convert to USD at sync time using the
  current FX rate before writing the `prices` row; store `currency:
  'USD'`. Finnhub is already USD — unchanged.
- **`portfolio_snapshots`**: derived from holdings + prices; once both are
  USD the snapshot is USD automatically. No special handling.

Conversion uses `storedToUsdAtDate` (fx_rates cache → live → fallback),
the same helper balance/flow show paths use.

### FX cache must cover all 9 markets' currencies

`fx_rates` currently caches KRW/JPY/EUR/CNY/GBP. The 9 markets need
KRW, JPY, HKD, GBP, EUR, INR, TWD. Missing: **HKD, INR, TWD**.

- Add to `FX_BY_CURRENCY` (domain): `HKD → DEXHKUS`, `INR → DEXINUS`,
  `TWD → DEXTAUS` (FRED, not inverted — "foreign per USD").
- Widen the `Currency` type, `CURRENCY_SYMBOL`, `CURRENCY_OPTIONS`,
  `FALLBACK_RATES`.
- The `firma sync fx` / `sync_fx_rates` backfill loop iterates these
  currencies — the 3 new ones are picked up automatically once added to
  the source list.
- CNY stays (no CN market, but already cached — harmless).

### Read side — display currency

- CLI already has `pickDisplayCurrency`. Extend honoring it to the
  transaction-bearing views that currently hard-render USD:
  `show portfolio` (holding rows, totals, sector/country alloc),
  `show txns`, `show snapshot`, `show dividend`, `brief` movers.
- `show portfolio` holding figures (cost, value, P&L) convert USD →
  display currency at the **current** rate (a holding aggregates many
  trade dates — single current rate is the consistent choice; the
  existing FX-decomposition section already analyses the FX effect).
- `show txns` converts each transaction at **its own trade date** via
  `usdToDisplayAtDate`.
- `--json` CLI output stays USD-canonical (machine-readable).

### MCP output stays USD-canonical

MCP `show_*` JSON keeps USD as the canonical unit. The analyst persona
reasons in USD (profile `target_net_worth` is USD), and JSON is a
machine format — a stable unit matters more than localisation. Tool
descriptions state values are USD. (If a home-currency display is wanted
later, it is an additive `currency` param — out of scope here.)

## Component changes

| Area | Change |
|---|---|
| `@firma/domain` macro | `FX_BY_CURRENCY` += HKD/INR/TWD |
| `apps/cli` `utils/currency.ts` | `Currency` union, `CURRENCY_SYMBOL`, `CURRENCY_OPTIONS`, `FALLBACK_RATES` += 3 |
| `add txn` CLI | native → USD at trade date; store `currency:'USD'`; show applied rate |
| MCP `add_txn`/`edit_txn`/`add_txns` | native price in, USD stored; description notes conversion |
| `sync` use-case | `toPrice` converts Yahoo native → USD; `currency:'USD'` |
| `show portfolio` CLI | rows/totals/alloc render in display currency |
| `show txns` CLI | per-trade-date display conversion |
| `show snapshot` / `show dividend` CLI | display conversion |
| `brief` | movers/values display-aware |

## Migration safety

No data migration. The multi-market branch is unreleased, so no user has
non-US native-currency transactions; existing `transactions` are all
`market='US'` with USD prices already. Schema is unchanged (the
`currency` columns already exist). Behaviour for US-only users is
identical.

## Testing

- FX backfill picks up HKD/INR/TWD.
- `add txn` / `add_txn`: KRW input → USD stored at trade-date rate.
- `sync`: Yahoo KRW price → USD stored.
- `show portfolio` / `show txns`: USD stored → display currency rendered.
- Extend the CLI/MCP parity harness.
- TDD throughout.
