# Manual (Non-Stock) Asset Tracking — Implementation Plan

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

**Goal:** Let firma users record and value non-stock assets (crypto, commodity, real estate, …) that have no price provider — via an explicit `asset_type` and manual price entry.

**Architecture:** Add `asset_type` to transactions; only `stock` is auto-synced. `market` becomes a nullable descriptive column (NULL for non-stock) and leaves all keys. `prices` / `portfolio_snapshots` are re-keyed by a single `symbol` identity string. A new `add price` command/tool sets the current value of non-stock holdings; `sync` skips them.

**Tech Stack:** TypeScript, Turborepo + Yarn Berry, Drizzle ORM + better-sqlite3, tsup, Vitest, Commander + Clack (CLI), `@modelcontextprotocol/sdk` (MCP).

**Design doc:** `docs/plans/2026-05-16-manual-assets-design.md`
**Branch:** `feat/manual-assets` (off `main`, PR #13 merged).
**Build order:** `@firma/finnhub` → `@firma/mcp` → `@evan-moon/firma`.
**Per-task verification:** `yarn typecheck` + `npx vitest run` must be green before commit.

---

## Task 1: Domain — `AssetType` + `symbol` construction

**Files:**
- Modify: `packages/domain/domain/src/market/symbol.ts`
- Test: `packages/domain/domain/src/market/symbol.test.ts`

**Scope:**
- Add `ASSET_TYPES = ['stock','crypto','commodity','real_estate','other'] as const` and `type AssetType`.
- Add `isKnownAssetType(s: string): s is AssetType`.
- Add `symbolOf({ assetType, market, code })`: returns `` `${market}:${code}` `` when `assetType === 'stock'`, else `` `${assetType}:${code}` ``. This is the canonical identity string for any asset.
- `symbolKey` / `parseSymbol` stay as-is (string split on first `:`); document that for a non-stock symbol the leading segment is the `assetType`, not a market.

**TDD:** test `symbolOf` for a stock (`{stock,KRX,005930}` → `"KRX:005930"`) and non-stock (`{crypto,null,BTC}` → `"crypto:BTC"`); test `isKnownAssetType`.

**Commit:** `feat(domain): add AssetType and symbolOf identity helper`

---

## Task 2: Schema — `asset_type`, nullable `market`, `symbol` keys

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

**Scope:**
- `transactions`: add `asset_type TEXT NOT NULL DEFAULT 'stock'`; change `market` to nullable (drop `.notNull()`, keep no default or default null).
- `prices`: add `symbol TEXT` column; PK `(market, ticker)` → `primaryKey({ columns: [symbol] })`; `market` → nullable.
- `portfolio_snapshots`: add `symbol TEXT`; unique `(date, market, ticker)` → `uniqueIndex` on `(date, symbol)`; `market` → nullable.
- Update `Transaction` / `Price` / `Snapshot` types in `types.ts`: `asset_type: AssetType`, `market: string | null`, `symbol: string`.

**Note:** schema change only — the migration that mutates real DBs is Task 3. No runtime test here; `yarn typecheck` is the gate.

**Commit:** `feat(db): asset_type column, nullable market, symbol keys in schema`

---

## Task 3: Migration — recreate tables with the new shape

**Files:**
- Modify: `packages/shared/db/src/client.ts`
- Test: `packages/shared/db/src/client.test.ts`

**Scope:** Mirror PR #13's `MIGRATIONS` + `TABLE_RECREATIONS` pattern.
- `MIGRATIONS`: `ALTER TABLE transactions ADD COLUMN asset_type TEXT NOT NULL DEFAULT 'stock'`.
- `TABLE_RECREATIONS` (transactional, idempotent) for all three tables — SQLite cannot drop `NOT NULL` or change a PK/UNIQUE in place:
  - `transactions`: recreate with `market` nullable. Copy all rows (`asset_type` already added by the migration).
  - `prices`: recreate with `symbol` PK + nullable `market`. `INSERT … SELECT …, market || ':' || ticker AS symbol` (existing rows are all stocks).
  - `portfolio_snapshots`: recreate with `(date, symbol)` unique + nullable `market`; `symbol = market || ':' || ticker`.
- Update the DDL `create table` strings in `client.ts` to the new shape.

**TDD:** extend `client.test.ts` — (1) an old-shape DB migrates: existing `prices`/`snapshots` rows get `symbol = "US:AAPL"` etc., no data loss; (2) migration is idempotent (runs twice cleanly); (3) schema-drift guard still passes.

**Commit:** `feat(db): migrate to asset_type + symbol-keyed prices/snapshots`

---

## Task 4: `aggregateHoldings` + repositories key by `symbol`

**Files:**
- Modify: `packages/shared/db/src/aggregate.ts`
- Modify: `packages/shared/db/src/repositories.ts`
- Test: `packages/shared/db/src/aggregate.test.ts`

**Scope:**
- `Holding` gains `asset_type: AssetType` and `market: string | null`.
- `aggregateHoldings` keys the map by `symbolOf({ assetType: t.asset_type, market: t.market, code: t.ticker })`.
- `getActiveTickers` returns each holding's `{ assetType, market, code }` (or a `symbol`) — callers updated.
- `repositories.ts`: `transactions.insert` carries `asset_type`; prices repo `getByTicker`/upsert key by `symbol`; snapshot repo keyed by `symbol`.

**TDD:** a stock and a non-stock with the same bare code do **not** merge; aggregation carries `asset_type` through.

**Commit:** `feat(db): aggregate holdings and repositories by symbol`

---

## Task 5: `sync` excludes non-stock holdings

**Files:**
- Modify: `packages/use-case/portfolio/src/sync.ts`
- Modify: `apps/cli/src/services/sync.ts`, `apps/mcp/src/tools/admin.ts` (only if signatures shift)
- Test: `packages/use-case/portfolio/src/sync.test.ts`

**Scope:** `syncPrices` fetches only holdings with `asset_type === 'stock'`. Non-stock holdings are never passed to a provider — no sync failure is reported for them, and any existing manual price row is left untouched.

**TDD:** a portfolio with one stock + one `commodity` holding → sync fetches only the stock; the commodity is absent from both results and failures.

**Commit:** `feat(sync): skip non-stock holdings`

---

## Task 6: CLI `add txn` — asset-type fork

**Files:**
- Modify: `apps/cli/src/commands/add.ts`
- Modify: `apps/cli/src/commands/txns.ts` etc. only if display breaks
- Test: `apps/cli/src/commands/add.test.ts`

**Scope:**
- `add txn` first prompts **asset type** (`select`, 5 options, `stock` default).
- `stock` → existing market + ticker flow unchanged.
- non-stock → prompt a free-form **name** (becomes `ticker`), a **currency** (`pickInputCurrency`-style), and **quantity** (default `1` for `real_estate` / `other`). `market` stored as `null`. Price = cost basis, converted to USD at the txn date (reuse `convertTxnPriceToUsd`).
- `repo.transactions.insert` carries `asset_type` and `market: null` for non-stock.
- CLI `edit txn`: allow editing `asset_type` (and clear `market` when it becomes non-stock).

**TDD:** a `commodity` txn is stored with `asset_type='commodity'`, `market=null`, price in USD.

**Commit:** `feat(cli): pick asset type when adding a transaction`

---

## Task 7: MCP `add_txn` / `edit_txn` / `add_txns` — `asset_type` param

**Files:**
- Modify: `apps/mcp/src/tools/mutate.ts`
- Test: `test/parity.test.ts` (or a focused parity test)

**Scope:**
- `add_txn` / `add_txns` / `edit_txn` accept an `asset_type` enum param (default `stock`). For non-stock, `market` is omitted/ignored and stored `null`; `ticker` is the free-form name.
- Update tool descriptions.
- Keep CLI ↔ MCP parity with Task 6.

**TDD:** MCP `add_txn` with `asset_type='crypto'` stores the same row a CLI `add txn` would.

**Commit:** `feat(mcp): accept asset_type in txn tools`

---

## Task 8: CLI `firma add price` — manual current-price entry

**Files:**
- Create: `apps/cli/src/commands/price.ts`
- Modify: `apps/cli/src/index.ts` (register `add price`)
- Test: `apps/cli/src/commands/price.test.ts`

**Scope:**
- `firma add price` — interactive: list every **non-stock** holding with its last manual price pre-filled; user updates in one batch pass (Enter keeps). Each value entered in the asset's currency → converted to USD at today's rate → written to the `prices` row keyed by `symbol` (`current_price`, `currency='USD'`, `synced_at`/as-of = today).
- `firma add price <name> <value>` — direct single-asset form.
- Running it against a `stock` holding → clear error ("stocks are auto-priced — use `firma sync`").
- Reuse the FX conversion + `formatCurrencyValue` helpers.

**TDD:** extract a pure helper for native→USD price conversion and test it; test that a stock target is rejected.

**Commit:** `feat(cli): add price command for manual asset valuation`

---

## Task 9: MCP `add_price` tool

**Files:**
- Modify: `apps/mcp/src/tools/` (the stock/portfolio tool module; add `add_price`)
- Test: parity test

**Scope:** MCP `add_price` — set the current price of a non-stock asset (native currency → USD). Mirrors CLI `add price`. Rejects stocks. Tool description states it is for non-stock assets only.

**TDD:** MCP `add_price` and CLI `add price` produce the same `prices` row.

**Commit:** `feat(mcp): add_price tool for manual asset valuation`

---

## Task 10: Display — non-stock rows + staleness

**Files:**
- Modify: `apps/cli/src/commands/portfolio.ts`
- Modify: `apps/cli/src/commands/doctor.ts`
- Modify: `apps/mcp/src/tools/portfolio.ts` (`show_portfolio` JSON exposes `asset_type`)
- Test: `apps/cli/src/commands/portfolio.test.ts`

**Scope:**
- `show portfolio` lists non-stock holdings as rows; current value from the manual price; append a staleness indicator (e.g. `· 23d`) computed from the price row's as-of date.
- `doctor` flags manual prices older than a sane threshold (single global default — no per-asset config) and suggests `firma add price`.
- `show_portfolio` / `--json` expose `asset_type` and the price as-of date. No prompting anywhere — passive nudge only.

**TDD:** a portfolio with a stale-priced `commodity` renders the staleness marker; `cur === 'USD'` output for an all-stock portfolio is byte-identical to before.

**Commit:** `feat(cli): show non-stock holdings with staleness indicator`

---

## Task 11: Parity harness + full verification

**Files:**
- Modify: `test/parity.test.ts`, `test/sync-*-parity.test.ts`
- Modify: `docs/plans/2026-05-16-manual-assets-design.md` (Status → Implemented)

**Scope:**
- Extend the CLI/MCP parity harness: a non-stock asset added + priced + shown via CLI and via MCP agree.
- Run `yarn typecheck` (expect all green), `yarn build` (15/15), `npx vitest run` (all green).
- Mark the design doc `Status: Implemented`.

**Commit:** `test: CLI/MCP parity for manual non-stock assets`

---

## Notes / explicitly out of scope

- Concentration-by-asset-type is a possible follow-up, not part of this plan (YAGNI).
- Auto-sync providers for gold/crypto are out of scope (design doc decision).
- After shipping: the `금현물` rows (`M04020000`, market `KRX`) default to `asset_type='stock'` and should be re-classified to `commodity` via `edit txn` — a one-off user data fix, not a migration.
