# Multi-Market Symbols — Design

Date: 2026-05-16
Status: Approved, ready for implementation plan

## Problem

`parseSymbol` supports a `MARKET:CODE` prefix syntax, and the provider
registry already routes non-US markets to the Yahoo adapter (`.KS`, `.T`,
…). But no application code ever produces a market prefix: every ticker
entered through `add txn` (CLI) or `add_txn` (MCP) is stored as a bare
string, which `parseSymbol` always resolves to `market: 'US'`. As a
result Finnhub (US-only) is the effective provider for everything, and
non-US holdings cannot be tracked correctly.

The Yahoo adapter comment "parseSymbol only emits 'US' today" is stale —
`parseSymbol` does parse prefixes; the gap is purely on the input side.

## Goal

Let users record holdings in any of the 9 markets the Yahoo adapter
supports, and route price/quote sync to the correct provider.

In scope: input UI, structured storage, provider routing, display.
Out of scope (tracked separately):

- FX cache covers KRW/JPY/EUR/CNY/GBP only — HK/NSE/TW holdings will not
  convert to USD accurately in portfolio valuation.
- Yahoo's keyless snapshot returns `sector`/`country` as null, so non-US
  holdings land in the "unknown" bucket of concentration's sector/country
  dimensions.

## Decisions

### Storage: structured `market` column, bare `ticker`

The domain model already is `SymbolRef = { market, code }`; the
`MARKET:CODE` string is only its serialization. The DB stores the
structured form so a future non-Yahoo backend can recombine market +
code freely.

- Add `market TEXT NOT NULL DEFAULT 'US'` to `transactions`, `prices`,
  `portfolio_snapshots`. (`balance_entries` / `flow_entries` have no
  ticker — untouched.)
- `ticker` holds the bare code only (`005930`, `7203`, `AAPL`).
- `'US'` default keeps every existing row valid — no behavior change for
  current users.

### Composite keys (table recreation)

Bare codes can collide across markets (e.g. JP `2330` vs TW `2330`), so
the cache keys must include `market`:

- `prices`: PK `ticker` → `(market, ticker)`.
- `portfolio_snapshots`: UNIQUE `(date, ticker)` → `(date, market, ticker)`.

SQLite cannot `ALTER` a PK/UNIQUE constraint. `client.ts` gets a one-time
recreation migration per table: create `*_new` with the new schema,
`INSERT … SELECT *, 'US'`, drop old, rename. The existing
`MIGRATIONS` array (ADD COLUMN only) handles `transactions.market`.

### Market list: all 9

`US, KRX, KOSDAQ, JP, HK, LSE, XETRA, NSE, TW` — the full set the Yahoo
suffix table already supports.

### Currency per market

New shared `MARKET_CURRENCY` map (in the `@firma/domain` market module):

```
US→USD  KRX→KRW  KOSDAQ→KRW  JP→JPY  HK→HKD
LSE→GBP  XETRA→EUR  NSE→INR  TW→TWD
```

CLI uses it for the transaction-currency default and price-input labels.
Today `add txn` hardcodes `currency: 'USD'` and labels prices `(USD)`;
both become market-derived.

## Component changes

### Domain / shared

- `MARKET_CURRENCY` map + a `MARKETS` list exported from the market module.
- `Holding.ticker` → keyed by `symbolKey({market, code})`; `Holding`
  gains a `market` field.
- `aggregateHoldings` keys the map by `symbolKey` instead of `t.ticker`.
- `getActiveTickers` returns `SymbolRef[]` instead of `string[]`.
- `db/types.ts` `Transaction` / `Price` / `Snapshot` gain `market`.
- Repository signatures that take a bare `ticker` (`getAll(ticker?)`,
  `getByTicker`, snapshot `update(date, ticker, …)`) take `market` too.

### Provider routing

`sync` builds a `SymbolRef` object from each `{market, ticker}` row and
passes it straight to the market client — `SymbolInput` already accepts
`SymbolRef`, so no string round-trip. Synced prices are written back with
`market`.

### CLI `add txn`

- New market `select` step (9 options, `US` first/default).
- `validateTicker` regex relaxed to `/^[A-Za-z0-9.^-]+$/` (codes are bare,
  digits allowed, no colon needed).
- Price-input labels and stored `currency` derived from `MARKET_CURRENCY`.
- `promptTicker` suggestions show `code` + market badge and carry market.

### Display — `tickerLabel(market, code)` helper

- `US` → `AAPL` unchanged.
- else → `005930` + `pc.dim('KRX')` badge.
- Applied in `show txns`, `show portfolio`, `show concentration`,
  `show snapshot`, `brief` movers, and `edit`/`delete` pickers.
- `--json` output exposes `market` and `ticker` as separate fields.

### MCP

- `add_txn`: new `market` enum param
  (`US|KRX|KOSDAQ|JP|HK|LSE|XETRA|NSE|TW`, default `US`); `ticker` is the
  bare code. Tool description notes non-US usage.
- `edit_txn`: optional `market`.
- `show_*` JSON output includes `market`.

## Testing

- `aggregateHoldings`: composite-key aggregation, no cross-market merge.
- `client.ts`: recreation migration copies existing rows with `market='US'`.
- Extend the CLI/MCP parity harness for market-aware `add`/`show`.
- TDD throughout.

## Migration safety

Existing user DBs: `transactions.market` via ADD COLUMN DEFAULT 'US';
`prices` / `portfolio_snapshots` recreated with every row copied as
`market='US'`. No data loss, no behavior change for US-only users.
