# Multi-Market Symbols Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Let users record and price-sync holdings in any of the 9 markets the Yahoo adapter supports, by storing `market` as a structured DB column alongside a bare `ticker` code.

**Architecture:** The domain already models a symbol as `SymbolRef = { market, code }`; this plan makes the DB store that structured form. Three ticker-bearing tables (`transactions`, `prices`, `portfolio_snapshots`) gain a `market` column; `prices`/`portfolio_snapshots` get composite keys. Holdings aggregate by `symbolKey`. Provider routing already works — it just needs real `market` values flowing in from `add txn` / `add_txn`.

**Tech Stack:** TypeScript, Turborepo, Yarn Berry, better-sqlite3 + Drizzle, Commander + Clack (CLI), MCP SDK + zod (MCP), Vitest.

Design doc: `docs/plans/2026-05-16-multi-market-symbols-design.md`.

**Conventions for every task below:**
- TDD: write the failing test, run it, see it fail, implement, see it pass.
- Run a package's tests with `yarn workspace <pkg> test` or from the package dir `yarn test`. Typecheck the repo with `yarn typecheck`.
- Build order when wiring CLI/MCP: `@firma/finnhub` → `@firma/mcp` → `@evan-moon/firma`.
- Commit after each task. End commit messages with the `Co-Authored-By` trailer.
- Do NOT push or open a PR until the user explicitly approves.

---

## Phase 1 — Domain foundation

### Task 1: Market list + currency map

**Files:**
- Modify: `packages/domain/domain/src/market/symbol.ts`
- Modify: `packages/domain/domain/src/index.ts` (export new symbols)
- Test: `packages/domain/domain/src/market/symbol.test.ts` (create if absent)

**Step 1: Write failing tests**

```ts
import { describe, expect, it } from 'vitest';
import { MARKETS, MARKET_CURRENCY, isKnownMarket } from './symbol.ts';

describe('markets', () => {
  it('lists all 9 supported markets with US first', () => {
    expect(MARKETS[0]).toBe('US');
    expect([...MARKETS].sort()).toEqual(
      ['HK', 'JP', 'KOSDAQ', 'KRX', 'LSE', 'NSE', 'TW', 'US', 'XETRA'].sort(),
    );
  });

  it('maps every market to a currency', () => {
    expect(MARKET_CURRENCY.US).toBe('USD');
    expect(MARKET_CURRENCY.KRX).toBe('KRW');
    expect(MARKET_CURRENCY.JP).toBe('JPY');
    for (const m of MARKETS) {
      expect(MARKET_CURRENCY[m]).toBeTruthy();
    }
  });

  it('recognises known vs unknown markets', () => {
    expect(isKnownMarket('KRX')).toBe(true);
    expect(isKnownMarket('NYSE')).toBe(false);
  });
});
```

**Step 2: Run — expect FAIL** (`MARKETS` not exported).

Run: `yarn workspace @firma/domain test symbol`

**Step 3: Implement** in `symbol.ts`:

```ts
export const MARKETS = ['US', 'KRX', 'KOSDAQ', 'JP', 'HK', 'LSE', 'XETRA', 'NSE', 'TW'] as const;
export type Market = (typeof MARKETS)[number];

export const MARKET_CURRENCY: Record<Market, string> = {
  US: 'USD',
  KRX: 'KRW',
  KOSDAQ: 'KRW',
  JP: 'JPY',
  HK: 'HKD',
  LSE: 'GBP',
  XETRA: 'EUR',
  NSE: 'INR',
  TW: 'TWD',
};

export const isKnownMarket = (m: string): m is Market => (MARKETS as readonly string[]).includes(m);
```

Re-export `MARKETS`, `MARKET_CURRENCY`, `Market`, `isKnownMarket` from `packages/domain/domain/src/index.ts` next to the existing `parseSymbol` export.

**Step 4: Run — expect PASS.**

**Step 5: Commit** `feat(domain): add market list and currency map`.

### Task 2: Remove the stale Yahoo adapter comment

**Files:**
- Modify: `packages/adapter/adapters/src/yahoo-finance.ts:11-13`

Delete the comment lines claiming "parseSymbol only emits 'US' today" — `parseSymbol` already parses prefixes; the suffix table is correct as-is. No test. Commit `docs: drop stale parseSymbol comment in yahoo adapter`.

---

## Phase 2 — Schema & migration

### Task 3: Add `market` to the Drizzle schema

**Files:**
- Modify: `packages/shared/db/src/schema.ts`

**Step 1:** Add `market: text('market').notNull().default('US')` to `transactions`, `prices`, and `portfolioSnapshots` table definitions (place it right after `ticker`).

**Step 2:** Change the `prices` primary key. `prices` currently declares `ticker` as `.primaryKey()` inline. Remove that and add a composite key via the table's second-argument callback:

```ts
export const prices = sqliteTable(
  'prices',
  {
    ticker: text('ticker').notNull(),
    market: text('market').notNull().default('US'),
    // ...rest unchanged...
  },
  (t) => [primaryKey({ columns: [t.market, t.ticker] })],
);
```

**Step 3:** Change `portfolioSnapshots` unique index from `(date, ticker)` to `(date, market, ticker)`:

```ts
(t) => [uniqueIndex('snapshot_uq').on(t.date, t.market, t.ticker)],
```

**Step 4:** `yarn workspace @firma/db typecheck` — expect PASS.

**Step 5: Commit** `feat(db): add market column to ticker-bearing tables`.

### Task 4: Migration — `transactions.market` + table recreation for `prices` / `portfolio_snapshots`

**Files:**
- Modify: `packages/shared/db/src/client.ts`
- Test: `packages/shared/db/src/client.test.ts` (create if absent)

**Step 1: Write failing test** — an old-shape DB upgrades without data loss:

```ts
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { createDbClient } from './client.ts';

const oldDbPath = () => join(mkdtempSync(join(tmpdir(), 'firma-')), 'old.db');

describe('market migration', () => {
  it('recreates prices/snapshots with market=US and keeps rows', () => {
    const path = oldDbPath();
    // simulate a pre-market DB
    const raw = new Database(path);
    raw.exec(`
      CREATE TABLE prices (ticker TEXT PRIMARY KEY, name TEXT NOT NULL,
        exchange TEXT NOT NULL DEFAULT '', currency TEXT NOT NULL DEFAULT 'USD',
        current_price REAL NOT NULL, prev_close REAL NOT NULL DEFAULT 0,
        change_percent REAL NOT NULL DEFAULT 0, high_52w REAL NOT NULL DEFAULT 0,
        low_52w REAL NOT NULL DEFAULT 0, pe REAL, eps REAL,
        market_cap REAL NOT NULL DEFAULT 0, sector TEXT, country TEXT,
        dividend_per_share REAL, dividend_yield REAL, synced_at TEXT NOT NULL);
      INSERT INTO prices (ticker, name, current_price, synced_at)
        VALUES ('AAPL', 'Apple', 100, '2026-01-01');
      CREATE TABLE portfolio_snapshots (id INTEGER PRIMARY KEY AUTOINCREMENT,
        date TEXT NOT NULL, ticker TEXT NOT NULL, shares REAL NOT NULL,
        avg_price REAL, current_price REAL NOT NULL,
        currency TEXT NOT NULL DEFAULT 'USD', UNIQUE (date, ticker));
      INSERT INTO portfolio_snapshots (date, ticker, shares, current_price)
        VALUES ('2026-01-01', 'AAPL', 10, 100);
    `);
    raw.close();

    createDbClient(path); // runs migrations

    const check = new Database(path);
    const price = check.prepare("SELECT market FROM prices WHERE ticker='AAPL'").get();
    const snap = check.prepare("SELECT market FROM portfolio_snapshots WHERE ticker='AAPL'").get();
    expect(price).toEqual({ market: 'US' });
    expect(snap).toEqual({ market: 'US' });
    // composite key present
    const pk = check.prepare('PRAGMA table_info(prices)').all() as { name: string; pk: number }[];
    expect(pk.filter((c) => c.pk > 0).map((c) => c.name).sort()).toEqual(['market', 'ticker']);
    check.close();
  });
});
```

**Step 2: Run — expect FAIL.**

Run: `yarn workspace @firma/db test client`

**Step 3: Implement.** In `client.ts`:

- Add `['transactions', 'market', "TEXT NOT NULL DEFAULT 'US'"]` to the `MIGRATIONS` array.
- Update the `DDL` string: add `market TEXT NOT NULL DEFAULT 'US'` to the `transactions`, `prices`, `portfolio_snapshots` `CREATE TABLE IF NOT EXISTS` bodies; change `prices` from `ticker TEXT PRIMARY KEY` to `ticker TEXT NOT NULL` + `PRIMARY KEY (market, ticker)`; change `portfolio_snapshots` `UNIQUE (date, ticker)` to `UNIQUE (date, market, ticker)`.
- Add a recreation step that runs *after* the DDL exec but is a no-op on fresh DBs. Detect "old shape" by checking whether `prices` already has a `market` column:

```ts
const hasColumn = (table: string, col: string) =>
  (sqlite.pragma(`table_info(${table})`) as { name: string }[]).some((c) => c.name === col);

const recreate = (table: string, createNew: string, copyCols: string) => {
  if (hasColumn(table, 'market')) return;
  sqlite.exec('BEGIN');
  sqlite.exec(createNew);
  sqlite.exec(`INSERT INTO ${table}_new (${copyCols}, market) SELECT ${copyCols}, 'US' FROM ${table}`);
  sqlite.exec(`DROP TABLE ${table}`);
  sqlite.exec(`ALTER TABLE ${table}_new RENAME TO ${table}`);
  sqlite.exec('COMMIT');
};
```

Call `recreate` for `prices` and `portfolio_snapshots` with `*_new` DDL mirroring the new shape, and `copyCols` listing every original column in order. NOTE: place this BEFORE `sqlite.exec(DDL)` is misleading — instead run `DDL` first (it only creates tables when absent, so old tables survive), then run `recreate`. Verify ordering by re-reading the test expectation.

**Step 4: Run — expect PASS.** Also run the full `@firma/db` suite to confirm no regression.

**Step 5: Commit** `feat(db): migrate prices/snapshots to composite market keys`.

---

## Phase 3 — Aggregation & types

### Task 5: `Holding` carries `market`; `aggregateHoldings` keys by `symbolKey`

**Files:**
- Modify: `packages/shared/db/src/types.ts` (`Holding`)
- Modify: `packages/shared/db/src/aggregate.ts`
- Modify: `packages/shared/db/src/aggregate.test.ts`

**Step 1: Write failing tests** — same code in two markets must not merge:

```ts
it('keeps same code in different markets as separate holdings', () => {
  const txns = [
    { id: 1, ticker: '2330', market: 'JP', date: '2026-01-01', type: 'buy', shares: 1, price: 10, currency: 'JPY', reason: null },
    { id: 2, ticker: '2330', market: 'TW', date: '2026-01-02', type: 'buy', shares: 5, price: 20, currency: 'TWD', reason: null },
  ];
  const map = aggregateHoldings(txns);
  expect(map.size).toBe(2);
  expect(map.get('JP:2330')?.shares).toBe(1);
  expect(map.get('TW:2330')?.shares).toBe(5);
  expect(map.get('TW:2330')?.market).toBe('TW');
});
```

Adjust existing `aggregate.test.ts` fixtures to include `market: 'US'` on every transaction object, and update any `map.get('AAPL')` to `map.get('US:AAPL')`.

**Step 2: Run — expect FAIL.**

**Step 3: Implement.**

`types.ts` — add `market: string;` and keep `ticker: string;` (now the bare code) on `Holding`.

`aggregate.ts` — import `symbolKey` from `@firma/domain`. Key the reducer map by `symbolKey({ market: t.market, code: t.ticker })`:

```ts
const map = sorted.reduce((acc, t) => {
  const key = symbolKey({ market: t.market, code: t.ticker });
  const h = acc.get(key) ?? { ticker: t.ticker, market: t.market, shares: 0, costShares: 0, totalCost: 0 };
  // ...unchanged buy/sell/deposit math...
  return acc.set(key, h);
}, new Map<string, Holding>());
```

`getActiveTickers` — return `SymbolRef[]`:

```ts
export const getActiveTickers = (txns: Transaction[]): SymbolRef[] =>
  [...aggregateHoldings(txns).values()].map((h) => ({ market: h.market, code: h.ticker }));
```

**Step 4: Run — expect PASS.**

**Step 5: Commit** `feat(db): aggregate holdings by composite market key`.

### Task 6: Repository signatures take `market`

**Files:**
- Modify: `packages/shared/db/src/repositories.ts`
- Modify: `packages/shared/db/src/repositories.test.ts` (if present)

`SnapshotRepository.getByTicker(ticker)` → `getByTicker(market, ticker)`; `update(date, ticker, fields)` → `update(date, market, ticker, fields)`. `TransactionRepository.getAll(ticker?)` → `getAll(filter?: { market: string; ticker: string })`. Update the `prices.upsertBatch` and `snapshots.upsert` `onConflict` targets to the new composite columns. Update every implementation body and any test. Run `yarn workspace @firma/db test`.

**Commit** `refactor(db): thread market through repository APIs`.

---

## Phase 4 — Use-cases & provider routing

### Task 7: `sync` builds `SymbolRef` and writes `market` back

**Files:**
- Modify: `packages/use-case/portfolio/src/sync.ts`
- Modify: `packages/use-case/portfolio/src/sync.test.ts`

**Step 1: Write/extend failing test** — a KRX holding syncs and the price row keeps `market: 'KRX'`. Mock `MarketDataClient.getStockSnapshot` to assert it received `{ market: 'KRX', code: '005930' }`.

**Step 2: Run — expect FAIL.**

**Step 3: Implement.**
- `getActiveTickers` now returns `SymbolRef[]` — `client.getStockSnapshot(t)` already accepts `SymbolInput` (which includes `SymbolRef`), so the `.map` body is unchanged but `t` is now an object.
- `toFailure(ticker, …)` callers pass `symbolKey(t)` or `${t.market}:${t.code}` for the human-readable id.
- `toPrice` must set `ticker: d.symbol.code` and `market: d.symbol.market` (StockSnapshot carries `symbol`). Confirm `StockSnapshot` exposes `symbol`; it does (see `provider.ts` types).
- `PriceSyncFailure.ticker` stays a string — use the `MARKET:CODE` form.

**Step 4: Run — expect PASS.**

**Step 5: Commit** `feat(sync): route price sync by holding market`.

### Task 8: `snapshot` & `concentration` price maps key by `symbolKey`

**Files:**
- Modify: `packages/use-case/portfolio/src/snapshot.ts` (`recordSnapshot`)
- Modify: `packages/use-case/portfolio/src/concentration.ts`
- Modify: their test files

In both, the holdings map is now keyed by `symbolKey`. Build `priceMap` with the same key: `new Map(prices.map((p) => [symbolKey({ market: p.market, code: p.ticker }), p]))`. In `recordSnapshot`, `repo.snapshots.upsert` must pass `market: holding.market` and `ticker: holding.ticker`. In `concentration.ts`, `Position` gains `market`; `by_ticker` dimension keys by `symbolKey` but the slice `label` should be the display form (see Task 9 helper) — for now key by `${market}:${ticker}` and fix the label in Task 11.

Write a concentration test proving JP `2330` and TW `2330` produce two `by_ticker` slices. Run `yarn workspace @firma/portfolio test`.

**Commit** `fix(portfolio): key snapshot/concentration by market`.

---

## Phase 5 — Display helper

### Task 9: `tickerLabel` shared CLI helper

**Files:**
- Create: `apps/cli/src/utils/ticker.ts`
- Modify: `apps/cli/src/utils/index.ts` (re-export)
- Test: `apps/cli/src/utils/ticker.test.ts`

**Step 1: Write failing tests:**

```ts
import { describe, expect, it } from 'vitest';
import { tickerLabel } from './ticker.ts';

describe('tickerLabel', () => {
  it('returns bare code for US', () => {
    expect(tickerLabel('US', 'AAPL')).toBe('AAPL');
  });
  it('appends a dim market badge for non-US', () => {
    // picocolors is disabled in test env, so dim() is identity
    expect(tickerLabel('KRX', '005930')).toBe('005930 KRX');
  });
});
```

**Step 2: Run — expect FAIL.**

**Step 3: Implement:**

```ts
import pc from 'picocolors';

export const tickerLabel = (market: string, code: string): string =>
  market === 'US' ? code : `${code} ${pc.dim(market)}`;
```

**Step 4: Run — expect PASS.**

**Step 5: Commit** `feat(cli): add tickerLabel display helper`.

---

## Phase 6 — CLI `add txn`

### Task 10: Market picker + market-aware currency in `add txn`

**Files:**
- Modify: `apps/cli/src/commands/add.ts`

**Step 1:** Relax `validateTicker` — regex `/^[A-Za-z0-9.^-]+$/`, message unchanged. Codes are bare (no colon), digits now allowed.

**Step 2:** Add a market `select` step. After the `type` select, before `promptTicker`:

```ts
import { MARKETS, MARKET_CURRENCY } from '@firma/domain';
// ...
const market = guard(
  await select({
    message: 'Market',
    options: MARKETS.map((m) => ({ value: m, label: m })),
    initialValue: 'US',
  }),
) as string;
const currency = MARKET_CURRENCY[market as keyof typeof MARKET_CURRENCY] ?? 'USD';
```

**Step 3:** `promptTicker` now receives `SymbolRef[]` suggestions. Render each option `label` with `tickerLabel(s.market, s.code)`, `value` as `symbolKey(s)`; on selection, split back to `{market, code}`. The sell path filters suggestions to the chosen `market`. Manual entry uses the `market` already chosen in Step 2.

**Step 4:** Replace the hardcoded `(USD)` price labels with `(${currency})` and the `currency: 'USD'` in `repo.transactions.insert` with `currency`. Add `market` to the insert object.

**Step 5:** The success summary uses `tickerLabel(market, ticker)` instead of bare `ticker`. The `priceRow` lookup (`repo.prices.getAll().find(...)`) must match on both `market` and `ticker`.

**Step 6:** `yarn typecheck`. Manually run `yarn dev:cli add txn`, add a `KRX` `005930` buy, confirm it stores with `market='KRX'`, `currency='KRW'` (inspect `~/.firma/firma.db` or `firma show txns`).

**Step 7: Commit** `feat(cli): pick market when adding a transaction`.

### Task 11: CLI display sites

**Files:**
- Modify: `apps/cli/src/commands/txns.ts`, `portfolio.ts`, `concentration.ts`, `snapshot.ts`, `brief.ts`, `edit.ts`, `delete.ts`

For each table/picker that prints a ticker, replace the bare `ticker` cell with `tickerLabel(row.market, row.ticker)`. Widen the `TICKER` column constant where needed (`txns.ts` `COL.TICKER`). For `--json` output, leave the raw rows as-is — they now include `market` as a real column, which is the desired separate-field output. In `txns.ts`, the `ticker` filter arg must match a `market:code` or be applied per-market; simplest: filter by `ticker` code only and show the market badge. `edit.ts` / `delete.ts` pickers label entries with `tickerLabel`. No new tests (rendering); verify by eye with `yarn dev:cli show txns`, `show portfolio`, `show concentration`.

**Commit** `feat(cli): show market badges across read commands`.

---

## Phase 7 — MCP

### Task 12: `add_txn` / `edit_txn` market param

**Files:**
- Modify: `apps/mcp/src/tools/mutate.ts`

`add_txn`: add `market: z.enum(['US','KRX','KOSDAQ','JP','HK','LSE','XETRA','NSE','TW']).default('US')`. Insert `market` and store `ticker: ticker.toUpperCase()` (the bare code). Update the tool description: "For non-US holdings set `market` (e.g. KRX for Korean stocks); `ticker` is the bare code." Return `market` in the `ok({...})` payload.

`edit_txn`: add `market: z.enum([...]).optional()`; thread it into the `fields` object.

Build order: `@firma/finnhub` → `@firma/mcp` → `@evan-moon/firma`. `yarn typecheck`.

**Commit** `feat(mcp): accept market in add_txn/edit_txn`.

### Task 13: MCP `show_*` outputs expose `market`

**Files:**
- Modify: `apps/mcp/src/tools/portfolio.ts`, `stock.ts`, `snapshot.ts` (any tool returning ticker rows)

Ensure returned JSON rows include the `market` field (they will automatically if the tool returns raw DB rows; add it explicitly where rows are hand-mapped). No behavior change for US-only data. `yarn typecheck`.

**Commit** `feat(mcp): include market in show_* output`.

---

## Phase 8 — Parity & verification

### Task 14: Extend the CLI/MCP parity harness

**Files:**
- Modify: the parity test files added in commits #9–#11 (locate with `git log --oneline --name-only | grep -i parit`).

Add a market-aware case: a non-US transaction added via CLI and via MCP must produce identical stored rows (`market`, `ticker`, `currency`). Run the parity suite.

**Commit** `test: extend CLI/MCP parity to multi-market holdings`.

### Task 15: Full verification

- `yarn typecheck` — clean.
- `yarn test` (or per-package) — all green.
- Build CLI + MCP in order; `yarn dev:cli sync` against a DB containing a `KRX` holding and confirm the Yahoo route returns a price.
- `firma doctor` still passes.
- Use the `verification-before-completion` skill before declaring done.

**Commit** none (verification only). Then STOP and report to the user — do not push or open a PR without approval.

---

## Risks / notes

- **FX coverage:** HK/NSE/TW currencies (HKD/INR/TWD) are not in the `fx_rates` cache. Portfolio USD valuation for those markets will be off. Out of scope here — flag as a follow-up issue.
- **Yahoo fundamentals:** non-US `sector`/`country` are null → "Unknown" bucket in concentration. Expected, documented in the design.
- **`prices` recreation migration** runs once; test Task 4 guards it. If it ever throws mid-transaction, the `BEGIN`/`COMMIT` wrapper rolls back — verify the test covers a re-run (call `createDbClient` twice).
