# Manual (Non-Stock) Asset Tracking — Design

Date: 2026-05-16
Status: Implemented
Builds on: PR #13 (`feat/domestic-stocks` — multi-market + USD storage)

## Problem

firma can only track listed stocks. The 9 markets all route to equity
price providers (Finnhub / Yahoo). A user holding KRX 금현물, crypto,
real estate, art, or any non-listed asset cannot record it meaningfully:
`add txn` only offers equity markets, and even once recorded, `sync` has
no price source so the holding shows no value.

Chasing a price provider per asset type is unbounded — there is always a
next un-supported asset. The fix is to make manual entry a first-class
path that covers the entire long tail at once.

## Core principle

Listed stocks are auto-tracked. Every other asset is recorded with an
explicit asset type and valued by **manual price entry**. Manual entry is
the floor that covers everything. Opportunistic auto-sync providers
(gold, crypto) are deliberately **out of scope** for this design — they
can be layered on later without changing this model.

## Decisions

### `asset_type` — explicit classification

New column on `transactions`: `asset_type TEXT NOT NULL DEFAULT 'stock'`.
Enum: `stock | crypto | commodity | real_estate | other`.

Only `stock` is auto-synced; everything else is manual. `asset_type` is
the auto/manual switch **and** a classification label (enables
concentration-by-type as a bonus). It is **not** a provider-routing
dimension — no per-class providers.

### `market` — nullable descriptive column

`market` is meaningful only for `stock` (the exchange). For non-stock
assets it is `NULL`. `market` is **removed from all keys**, so `NULL` is
both safe and the correct SQL representation of "not applicable". The
column now has one consistent meaning: "listed exchange, or NULL".

This reverses PR #13's `(market, ticker)` key, which baked in the
assumption that every asset has a market.

### `symbol` — single-column identity

`prices` and `portfolio_snapshots` are re-keyed:

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

`symbol` is the identity string: `"<market>:<ticker>"` for stocks,
`"<asset_type>:<name>"` for non-stock — e.g. `"KRX:005930"`,
`"crypto:BTC"`, `"commodity:금괴"`. `symbol`'s purpose never varies (it is
always "the unique asset id"); only its internal composition differs,
which is normal for an identifier.

`transactions` needs no `symbol` column — aggregation computes it from
`(asset_type, market, ticker)`. Re-keying needs table recreation; PR #13's
recreation machinery in `client.ts` handles exactly this shape of change.

### Input

- **`add txn` (CLI) / `add_txn` (MCP)**: first step chooses asset type.
  `stock` → existing market + ticker flow. Non-stock → free-form name,
  currency, quantity (default 1 for `real_estate` / `other`). Price is the
  cost basis, converted to USD per the storage model.
- **`firma add price` / MCP `add_price`**: set/update the current price of
  non-stock holdings. The interactive CLI form lists every non-stock
  holding with its last value pre-filled (batch entry). Native currency →
  USD at today's rate. Run against a `stock` → rejected ("use `sync`").

### Sync

`sync` / `sync_prices` fetch only `asset_type='stock'` holdings. Non-stock
assets are never fetched — no failure noise, manual prices preserved.

### Display

`show portfolio` lists non-stock holdings as rows, current value from the
manual price, with a staleness indicator (`₩X · 23d`). `doctor` flags
stale manual prices. Passive nudge only.

### Out of scope (deliberately cut)

- Staleness-gated prompting, per-asset staleness thresholds, prompt
  injection into sync flows — a passive staleness indicator is enough.
- Asset-class provider routing / auto-sync for gold or crypto.

## Component changes

| Area | Change |
|---|---|
| `@firma/db` schema | `transactions.asset_type` (NOT NULL DEFAULT 'stock'); `market` → nullable; `prices` PK → `symbol`; `portfolio_snapshots` UNIQUE → `(date, symbol)` |
| `@firma/db` `client.ts` | migration: ADD COLUMN `asset_type`; table recreation for `prices` / `portfolio_snapshots` keyed by `symbol` |
| `@firma/domain` | `symbol` construction for stock vs non-stock; `AssetType` enum |
| `aggregateHoldings` / repositories | key by `symbol`; carry `asset_type` |
| `add txn` CLI / `add_txn` MCP | asset-type fork |
| `add price` CLI / `add_price` MCP | new — manual current-price entry |
| `sync` use-case + CLI/MCP | filter to `asset_type='stock'` |
| `show portfolio` / `doctor` | non-stock rows + staleness indicator |

## Migration safety

- `asset_type` DEFAULT `'stock'` → all existing transactions become
  `stock`. No behavior change for current users.
- `market` → nullable: existing rows keep their value; only future
  non-stock rows are `NULL`.
- `prices` / `portfolio_snapshots` recreated with `symbol` computed from
  the existing `(market, ticker)` — no data loss.
- `금현물` (`M04020000`, market `KRX`) defaults to `asset_type='stock'` but
  is really `commodity` → user re-classifies via `edit txn` after the
  feature ships (one-off, like the earlier currency data fix).

## Testing

- `asset_type` default migration; `symbol` re-key migration (rows
  preserved, keys correct).
- `add txn` non-stock flow; `add price` batch entry + currency conversion;
  `sync` excludes non-stock holdings.
- Extend the CLI/MCP parity harness for the non-stock flows.
- TDD throughout.

## Branch

A new branch `feat/manual-assets` off `feat/domestic-stocks` — the
`symbol` re-key depends on PR #13's schema and recreation machinery.
