Skip to content

ADR-003: portfolio.md Upgrade to v2 Generic Schema ​

  • Status: ✅ Accepted
  • Date: 2026-05-06
  • Decision Makers: longsizhuo + Claude

← ADR Index · Full Data Model Document


Context ​

v0.1 portfolio.md used flat fields:

yaml
---
cash_cny: 50000
aud_cash: 1000
ndq_shares: 50
ndq_avg_cost_aud_per_share: 38.50
gold_grams: 124
gold_avg_cost_cny_per_gram: 1008.79
---

Problems:

  • Adding a new asset (e.g., Apple AAPL) required code changes in 4-5 places: portfolio.md fields / PortfolioManager getter / render_body template / NapCat commands / Web API endpoint
  • Adding a new currency (e.g., USD/EUR) was similar
  • The number of fields kept growing, making schema maintenance costs exponential
  • Users wanting to "track a stock" had to modify code and redeploy

Observed that yfinance covers stocks / ETFs / Mutual Funds / Cryptocurrencies / Commodity Futures / Exchange Rates → Any yfinance symbol should be addable.


Decision ​

Upgrade to v2 schema:

yaml
---
schema_version: 2
cash:
  CNY: 50000
  AUD: 1000
  USD: 0
holdings:
  - symbol: NDQ.AX
    kind: etf
    units: 50
    unit_label: shares
    avg_cost: 38.50
    cost_currency: AUD
    channel: CommSec
    proxy_kind: direct
  - symbol: GC=F
    kind: metal
    units: 124.0
    unit_label: grams
    avg_cost: 1008.79
    cost_currency: CNY
    channel: Zheshang Accumulated Gold
    yfinance_proxy: GC=F
    proxy_kind: gold_cny_per_gram
---

Core Abstraction:

  • cash: dict[currency_code, amount]
  • holdings: list[holding], each self-describing (symbol / kind / units / unit_label / cost / channel / proxy)

Consequences ​

Positive ​

  1. Zero code changes for new assets: User adds asset in GUI → inputs symbol → yfinance Search → selects → submits. Backend /api/holdings POST automatically appends to the holdings list.
  2. Zero code changes for new currencies: cash["USD"] = 100 takes effect immediately.
  3. Supports any yfinance ticker: Stocks (AAPL) / ETFs (QQQ) / HK Stocks (0700.HK) / A-Shares (005827.SS) / Crypto (BTC-USD) / FX (USDCNY=X)
  4. proxy_kind unifies abstract proxy quotes: Zheshang Accumulated Gold (gold + USD/CNY reverse-calculated gram price) can also fit into the same schema.
  5. Tracking-only holdings (is_tracking_only): Users can "take a look at this stock" without pretending to hold it.
  6. Reduced code volume: Removed 5 dedicated getters (gold_grams_held / ndq_shares etc.)

Negative ​

  1. Data nesting is one level deeper: p["holdings"][i]["units"] instead of p["ndq_shares"]
  2. Code becomes more verbose: Finding a holding requires next((h for h in holdings if h.get("symbol") == sym), None)
  3. Increased Pydantic schema complexity: HoldingV2 has 12+ fields vs. 5 flat fields previously.
  4. NapCat 11 commands need rewriting: /balance changes from p["cash_cny"] to pm.cash_amount("CNY"), all 6 write commands need modification.
  5. Requires v1 read-time fallback: To prevent the old portfolio.md from breaking immediately.

Alternatives Considered ​

A. Keep v1 flat, add new fields for new assets ​

yaml
cash_cny: 50000
aud_cash: 1000
usd_cash: 0
ndq_shares: 50
aapl_shares: 0
btc_units: 0
...

Rejected: Each new asset requires 5 changes + portfolio.md fields grow longer + cannot dynamically track arbitrary symbols.

B. v2 but using SQLite ​

Store portfolio in SQLite table holdings(symbol, kind, units, ...).

Rejected:

  • Loses the benefit of frontmatter Markdown being "LLM-readable + git diff friendly"
  • Backup becomes complex (.db files cannot be safely cp)
  • Concurrency safety needs redesign (fcntl file locking is insufficient)

C. v2 but holdings as dict instead of list ​

yaml
holdings:
  NDQ.AX:
    kind: etf
    units: 50
  GC=F:
    kind: metal
    units: 124

Rejected: Dict order in YAML is unstable (different libraries may reorder); list has inherent order, ensuring consistency in UI display.

D. Hard migration for v0.x users (no fallback) ​

Force a migration script on startup; old portfolio.md is not backward compatible.

Rejected:

  • Initial migration failure could lead to data loss
  • Users unaware of the schema upgrade would have a poor experience seeing errors

→ Decision: Add read-time fallback, automatically clean up old fields on write (lazy migration).


Implementation Details ​

Read-time Fallback ​

PortfolioManager.cash and holdings are properties that detect v1 fields on read and automatically construct an equivalent v2 view:

python
@property
def cash(self) -> Dict[str, float]:
    cash = dict(self.portfolio.get("cash") or {})
    if not cash:
        cny = self.portfolio.get("cash_cny")
        aud = self.portfolio.get("aud_cash")
        if cny is not None: cash["CNY"] = float(cny)
        if aud is not None: cash["AUD"] = float(aud)
    return cash

→ A v1 portfolio.md automatically behaves like v2 when first read, business code is unaware.

Write-time Automatic Cleanup ​

with_portfolio_tx() cleans up v1 old fields on exit and marks schema_version: 2:

python
with self.store.transaction("portfolio") as p:
    _ensure_v2_inplace(p)  # Automatic fallback before entering `with`
    yield p
    # Before commit: clean up v1 old fields
    for k in ("cash_cny", "aud_cash", "ndq_shares",
              "ndq_avg_cost_aud_per_share",
              "gold_grams", "gold_avg_cost_cny_per_gram"):
        p.metadata.pop(k, None)
    p["schema_version"] = 2
    validate_portfolio(dict(p.metadata))
    p.set_body(_render_portfolio_body_v2(p))

→ A v1 portfolio.md automatically upgrades to v2 when first written. Zero downtime.

NapCat 11 Command Rewrites ​

All commands in connectors/napcat_bot.py are switched:

Old v1New v2
p["cash_cny"] += amountcash["CNY"] = cash.get("CNY", 0) + amount
p["gold_grams"] = gramsupsert holding GC=F's units
p.get("aud_cash", 0)pm.cash_amount("AUD")

Added 18 fixture tests covering happy path / insufficient balance rollback / multi-currency / RMW consistency.

Pydantic Schema ​

core/schemas.py:PortfolioData v2 strict schema:

  • cash must be dict[str, NonNegativeFloat]
  • holdings must be list[HoldingItem]
  • HoldingItem required fields: symbol / kind / units / unit_label / avg_cost / cost_currency / channel

Reference ​

  • Full Data Model: 05-data-model.md
  • v2 commit: 3353d65 feat: v2 generic data model + v3 transparency + live multi-round real debate + 18 README
  • NapCat v2 switch + tests: 4cc1db3 feat(napcat): 11 commands switch to v2 data model + 18 fixture tests
  • Migration script: scripts/migrate_portfolio_to_holdings.py

Review Trigger Conditions ​

  1. Pydantic schema complexity becomes unmanageable (HoldingV2 fields > 25)
  2. Performance bottlenecks appear (frequent read-time fallback, though extremely unlikely for single-user GB-level data)
  3. 100+ holdings make list lookups slow (should change to dict-by-symbol index but retain list serialization)
  4. v3 schema emerges (e.g., adding lots / tax_lots / cost_basis_method)