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:
---
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:
---
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
- Zero code changes for new assets: User adds asset in GUI → inputs symbol → yfinance Search → selects → submits. Backend
/api/holdingsPOST automatically appends to theholdingslist. - Zero code changes for new currencies:
cash["USD"] = 100takes effect immediately. - Supports any yfinance ticker: Stocks (AAPL) / ETFs (QQQ) / HK Stocks (0700.HK) / A-Shares (005827.SS) / Crypto (BTC-USD) / FX (USDCNY=X)
proxy_kindunifies abstract proxy quotes: Zheshang Accumulated Gold (gold + USD/CNY reverse-calculated gram price) can also fit into the same schema.- Tracking-only holdings (
is_tracking_only): Users can "take a look at this stock" without pretending to hold it. - Reduced code volume: Removed 5 dedicated getters (
gold_grams_held/ndq_sharesetc.)
Negative
- Data nesting is one level deeper:
p["holdings"][i]["units"]instead ofp["ndq_shares"] - Code becomes more verbose: Finding a holding requires
next((h for h in holdings if h.get("symbol") == sym), None) - Increased Pydantic schema complexity: HoldingV2 has 12+ fields vs. 5 flat fields previously.
- NapCat 11 commands need rewriting:
/balancechanges fromp["cash_cny"]topm.cash_amount("CNY"), all 6 write commands need modification. - 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
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 (
.dbfiles cannot be safelycp) - Concurrency safety needs redesign (fcntl file locking is insufficient)
C. v2 but holdings as dict instead of list
holdings:
NDQ.AX:
kind: etf
units: 50
GC=F:
kind: metal
units: 124Rejected: 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:
@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:
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 v1 | New v2 |
|---|---|
p["cash_cny"] += amount | cash["CNY"] = cash.get("CNY", 0) + amount |
p["gold_grams"] = grams | upsert 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:
cashmust bedict[str, NonNegativeFloat]holdingsmust belist[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
- Pydantic schema complexity becomes unmanageable (HoldingV2 fields > 25)
- Performance bottlenecks appear (frequent read-time fallback, though extremely unlikely for single-user GB-level data)
- 100+ holdings make list lookups slow (should change to dict-by-symbol index but retain list serialization)
- v3 schema emerges (e.g., adding lots / tax_lots / cost_basis_method)