Cross-currency taxonomy plan (2026-09-18)
Settled with Fred 2026-09-18 in one conversation, after the prod wallet 0xb8b827eafb19df6737935b3b3f6bef855d2f8e84 showed two WBTC-collateralised USDT loans (Morpho, $2.29M/$1.23M and $396k/$202k) under "Not in the USD or ETH views" with the reason "No dollar or ether claim". Every decision below is Fred's; the implementation notes are the orchestrator's reading of the code at origin/staging 21c97f46 and are to be verified by the implementer, not trusted.
Deviation, recorded with the build (PR #921). Section 2.3 files every debt-free leg whose base has no currency book under Repo lending, with the reason
"debt-free". One shape is built otherwise: a principal token posted at a venue with nothing borrowed against it, whose payout asset this product does not track. That token carries no price at all, so there is no unit to state it in, and a band whose subtotal is a figure cannot hold a holding with no figure. It keepsreason: "payout-asset-not-tracked"andcategory: null, which lists it under Not covered beside the two borrowings there. The rest of section 2.3 is built as written and nothing in section 1 moved.
1. The rule set (decision record)
The base of an asset is the book its registry row carries: USD, ETH, or EXCLUDED (a priced asset with no currency book: bitcoin and its wrappers, gold, apxUSD, a governance token). "EXCLUDED" is a base, not a coverage verdict. The 2026-09-16 coverage rule already prices every registry token, so an EXCLUDED leg carries a dollar value_market by construction (readingUsd returns it as-is).
| Shape | Category / band | Views |
|---|---|---|
| Borrow position, every leg one real base (all USD or all ETH) | Carry trade (unchanged) | its currency view + All |
| Borrow position, any other base mix (USD+ETH, BTC+USD, BTC+BTC, gold+USD, mixed Aave accounts, Fluid mixed pools with a borrow, ...) with priced collateral | Cross-currency borrowing | All only, value + net, no rate |
| Single-asset supply, no borrow, any base (WBTC on Aave/Morpho/Fluid included) | Repo lending | currency view + All when the base is USD/ETH; All only when the base is EXCLUDED |
| Fluid smart (DEX-pool) collateral, no borrow, same base (wstETH/ETH) | Smart repo lending (new) | its currency view + All |
| Fluid smart collateral, no borrow, mixed base (ETH/USDC, WBTC/cbBTC) | Smart repo lending (new) | All only |
| A borrow whose collateral could not be read (bare debt: read failure > 1 tick, residual bad debt after a full seizure) | Not covered (new band, replaces "Not in the USD or ETH views") | All only, listed, outside the total and the value history |
| A borrow against collateral that cannot be priced (a pledged Pendle PT whose payout asset is untracked) | Not covered | same |
| Bare Pendle PT on an untracked payout asset | hidden (2026-09-17 rule, unchanged) | nowhere |
| Bare wallet balance of an EXCLUDED asset (WBTC in the wallet) | Idle assets (unchanged) | All only |
Consequences Fred accepted explicitly:
- An Aave account holding a same-currency loop plus an enabled EXCLUDED collateral (USDC + XAUt collateral, USDT debt) is ONE cross-currency borrowing; it stops showing as a USD carry. A supply NOT enabled as collateral still stays behind as its own Repo lending row (#717 D2, unchanged).
- Same-base smart pools with a borrow (wstETH/ETH pool, ETH debt) stay carry trades in the ETH view.
- Accrual and total return exist only inside a currency view. Cross-currency and All-only rows state value; nothing changes in the return engine.
- "Not covered" positions are removed from the All view's headline total and from its value history. The hero and the chart card carry a caption stating how many positions are excluded. A real debt sitting there no longer reduces the headline; the caption is the disclosure.
- The Fluid "directional pair" concept (two bases on one side = a bet, held out of every view) is retired. A mixed side is a base mix like any other.
2. Server: classification (src/lib/portfolio/pnl.ts, v2/segments.ts)
Read-time only. No migration, no data step, no stored classification changes.
groupIsCrossAsset(legs, opts): new membership rule for every venue. Let B = the set of bases (Book, EXCLUDED included as its own member) over the group's collateral legs (Aave/Spark: enabled collateral only, as today) and debt legs, observed at this ts or carried in (opts.carried, which must now carry EXCLUDED too). Cross-asset iff the group has a debt (observed or carried) AND a collateral (observed or carried) ANDBis not exactly one real book ({USD}or{ETH}). Morpho: the pure-lend:supplyleg stays out of the test. Fluid: use the side's history-wide base set (below) so an absent pool-token row cannot flip the verdict tick to tick.fluidVaultCoverage: replace{directional, unmapped}with per-side base sets (sideBases: ReadonlyMap<sideKey, ReadonlySet<Book>>, EXCLUDED counted). Deletedirectional/unmappedand every consumer. The Fluid branch ofclassifyLegsAtTsandgroupIsCrossAssetread the side's base set.classifyLegsAtTsoutcomes per group (all venues), in this order:- Aave/Spark: uncollateralized supplies carved out first (unchanged); an EXCLUDED one becomes
included:false, book:null, reason:"debt-free", category:"repo_lending". - cross-asset group (rule 1): EVERY leg
included:true, reason:"cross-asset", groupKey \${gk}:cross`, book: the leg's own book (EXCLUDED allowed), category "carry_trade". No leg of a cross-asset group is peeled off any more (the Aave${gk}:excluded` split and the Morpho/Fluid all-or-nothing gates go). - same-book debt group (one real book): unchanged (
same-book-debt/same-book-carry, carry_trade). - debt group with no collateral observed nor carried:
included:false, reason:"cross-book", category:null(Not covered). - debt group whose collateral legs are all unpriceable by construction (
ptPayoutUntracked):included:false, reason:"payout-asset-not-tracked", category:null(Not covered). A group mixing a priced collateral and an untracked PT is cross-asset (the PT leg shows with its value withheld, as a pledged PT does today). - debt-free group: per leg. Real-book leg:
included:true, reason:"debt-free"(unchanged). EXCLUDED leg:included:false, book:null, reason:"debt-free". Category:smart_repo_lendingwhen the leg is a Fluid smart leg (seven-part keyfluid:vault:<v>:nft:<id>:<token>:<side>), elserepo_lending; for erc4626/pendle the existing role-based category, whatever the book. - A debt-free Fluid NFT whose collateral side spans two real books (ETH/USDC smart collateral):
included:falsefor every leg (no currency view), categorysmart_repo_lending, reason"debt-free", book null.
- Aave/Spark: uncollateralized supplies carved out first (unchanged); an EXCLUDED one becomes
categoryForInclusionbecomes "category from venue + reason + smartness, independent ofincluded" for every venue, the way wallet legs already work.category:nullis reserved for the two Not covered reasons and the hidden bare PT.InclusionReason: drop"directional-pair".REASON_LABEL(api-types) keeps only what the Not covered band prints:cross-book→ "Debt without matched collateral",payout-asset-not-tracked→ "Payout asset not tracked"; the other entries remain for the API'sreasonfield but are never rendered.v2/segments.tsviewFor:reason === "cross-asset"answers"ALL"before the EXCLUDED guard. Audit every other reader ofLegVerdict.book/included(coverage.ts,markers.ts,cross-book-seizure.ts,capital.ts,ledger-v2-api.tscurveLegsFor/viewSpans,classify.ts) for the new combinationincluded && book === "EXCLUDED"; the invariant is "an included leg is either in a real book or in a cross-asset group".
3. Server: serving (src/lib/portfolio/ledger-v2-api.ts, v2/all-value.ts, api-types.ts, types.ts)
Categorygains"smart_repo_lending";CATEGORY_ORDERplaces it right afterrepo_lending;CATEGORY_LABEL.smart_repo_lending = "Smart repo lending".- Charted-row builder (
verdict.included): an EXCLUDED leg in a cross-asset group is served in the ALL bucket withbook: "USD"(itsvalueMarketis already dollars),charted: true,category: "carry_trade".PositionRow.bookstaysRealBook. - Uncharted-row builder (
verdict.category != null && !verdict.included): serve EVERY venue, not onlywallet. Keep the leg's real book where it has one (an ETH-book leg of a mixed smart pool servesbook: "ETH",valueMarketin ETH,valueUsdconverted throughusdPerBook); EXCLUDED servesbook: "USD". Label, quantity and annotation through the same helpers the charted builder uses (labelForRow,servedQuantity,annotationForRow). buildV2Outsideis unchanged in shape (wire keyoutsidestays) and now holds only category-null groups, i.e. the two Not covered reasons.- Value history:
allValueReadingsdrops a reading when the leg's verdict in force at that reading's ts hascategory === null(use the segments; fall back to nothing else). Such a reading no longer marks the pointincomplete.HistoryResponseforALLgainsexcluded: number= the count of Not covered groups at the tip (0 when none), so the chart card can caption without a second fetch. Hidden bare PTs keep their existing filter. docs/metrics.mdM22 membership text, M14 Fluid text and the "outside the yield book" passages are rewritten to §1 (see §5).
4. Client (src/components/portfolio/signed-in-model.ts, PortfolioDashboard.tsx, signed-in-theme.ts)
AllBand:"outside"→"not_covered";ALL_BANDS=[...CATEGORY_ORDER, "cross_currency", "not_covered"]. Label "Not covered". Note: "Borrowings this product cannot value: the collateral could not be read or has no price. Listed for completeness, outside the total and the chart." (no em-dashes, no internals).allHoldings: unchanged mechanics; uncharted ALL-bucket rows of any venue take the served category band (so a WBTC supply on Aave lands under Repo lending and a mixed smart pool under Smart repo lending).allTotal: skipnot_coveredentries entirely (they neither add nor withhold); returnexcluded: number(count of skipped entries). Hero caption whenexcluded > 0: "Excludes {n} position(s) not covered". Same caption on the All view chart card, fromHistoryResponse.excluded. Band subtotal for Not covered still prints (it is the band's own figure).CategorySection:smart_repo_lendingrenders aSmartRepoSectionthat reuses the repo row layout with the pool pair in the Asset cell (both tokens, one line each, the way the cross-currency side cell prints legs) and the same rate/yield cells as Repo lending. Works in the denomination tables and in All.CAT_COLOR.smart_repo_lending: a distinct hue near repo lending's blue; checkglobals.cssrow styles keyed onrepo_lendingand extend where the row layout is shared.- Cross-currency section: no code change expected beyond what the broadened membership produces; verify a bitcoin leg prints "30.0254 WBTC" + dollar value and the net is stated; verify an Aave entry with an EXCLUDED collateral leg lists it inside the entry (not beside it).
OutsideSection→NotCoveredSection(rename, same $10 floor affordance).- Allocation split (
Allocation,CAT_COLOR, the hero's split bar) extends viaCATEGORY_ORDERautomatically; confirm noRecord<Category,…>literal is left short (TypeScript will tell).
5. Docs (same PR)
docs/portfolio.md: the All view band list (~L705-745), the Aave account rule (~L228-250), the bitcoin passage (~L385-398), the taxonomy category list (add Smart repo lending), every mention of "Not in the USD or ETH views".docs/metrics.md: M22 membership and unit table, M14 Fluid directional/unmapped text, the "outside the yield book" passages (~L3315-3330, ~L4438, ~L4711), the taxonomy list;docs/index.mdL13.docs/plans/portfolio-taxonomy-coverage-plan.md: headercurrent:line only (body frozen) pointing here. This file: header flipped tobuiltwith the PR number inlandedin the same PR. Regeneratedocs/plans/index.md(cd docs && npm run plans:index).npm --prefix docs run buildmust pass.prompts/(assistant rules): grep for category names; update if they are listed.
6. Fixture + e2e (scripts/fixture/seed.sql, tests/e2e/)
The main fixture wallet already holds every shape but the smart pools: XAUt (EXCLUDED) supply on the financed Aave account (→ now inside the cross-currency entry), apxUSD (EXCLUDED) collateral + USDC debt on Morpho market …7002 (→ Cross-currency borrowing), a dust apxUSD collateral-only market …7003 (→ Repo lending, All only, $3.48 so above the All view's $1 floor), a debt-only Morpho market (→ Not covered), a bare PT on wallet 0x9999 (hidden, unchanged), a WBTC wallet balance (Idle, unchanged).
Add, on a NEW account + wallet of its own (so every count in portfolio.spec.ts stays put, as the account-boundary wallet does): one Fluid smart-collateral NFT with a mixed pool (WETH + USDC legs, seven-part keys, no debt) and one with a same-base pool (wstETH + WETH legs, no debt), each present on every grid point.
New spec tests/e2e/portfolio-cross-currency-taxonomy.spec.ts (structure and counts, never market numbers; gate on hydration; 1360/1140/900):
- Cross-currency borrowing lists the apxUSD/USDC market as one entry with both legs and a net; the Aave entry lists the XAUt leg inside it.
- Repo lending on All lists the apxUSD collateral-only market.
- Not covered lists the debt-only market; the hero total equals the sum of the other bands' subtotals; the caption names 1 excluded position; the chart card carries the caption.
- The smart wallet: Smart repo lending shows both NFTs on All, and only the same-base NFT in the ETH view; the USD view shows neither. Update the existing specs that assert the old band (
portfolio.spec.tsoutside section + its "N below the minimum" affordance,portfolio-column-alignment.spec.tsband list) and re-runportfolio-account-boundary.spec.tsandportfolio-cross-book-liquidation.spec.tsunchanged.
7. Acceptance (each verified by the reviewer, one by one)
A1. On the fixture, the apxUSD/USDC market renders under Cross-currency borrowing with a net; nothing renders under a band named "Not in the USD or ETH views" anywhere in the app. A2. The Aave financed account is one entry whose legs include XAUt; XAUt has no row of its own. A3. The apxUSD collateral-only market is under Repo lending on All, absent from the USD and ETH views. A4. The debt-only market is under Not covered; the All hero total excludes it and says so; the All history points exclude its readings (unit test on allValueReadings/buildV2AllHistory with a bare-debt leg: value unchanged when the leg is added). A5. viewFor returns "ALL" for every leg of a cross-asset group including EXCLUDED ones; no USD/ETH curve contains them (segments test). A6. Fluid: mixed-pool NFT without debt → smart_repo_lending, All only; same-base pool NFT without debt → smart_repo_lending in its book; same-base pool NFT with same-base debt → carry_trade; mixed-pool NFT with debt → cross-asset. Verdicts stable when one pool-token row is absent at a tick (history-wide side bases). A7. Aave: USDC + enabled XAUt collateral + USDT debt → one cross-asset account; USDC + XAUt NOT enabled + USDT debt → USD carry + XAUt repo lending (All only). WBTC debt against USDC → cross-asset. A8. Morpho: WBTC collateral + USDT debt → cross-asset; WBTC collateral only → repo_lending (All only); WBTC pure-lend :supply → repo_lending (All only); bare debt → Not covered. A9. npm test, npx tsc --noEmit, npm --prefix docs run build exit 0; npm run e2e -- tests/e2e/portfolio*.spec.ts green on the fixture; CI green on the PR's final commit. A10. No user-facing copy contains an em-dash or an internal name; the Not covered note and the two captions read in product terms.
8. Out of scope
A rate or return for cross-currency rows; any bitcoin or third currency view; renaming wire keys (outside, managed_strategy_fund); changes to stored rows, migrations, crons, alerts (WS8 reads stored EXCLUDED rows and is untouched); Aave V4; the fund registry.
9. Second-order effects the review must trace
- Wallets whose USD/ETH carry becomes cross-currency lose that carry's history from the USD/ETH curves on deploy (a restatement, by decision). Confirm the handover interval logic in segments treats it as a view move, not a close.
- Cross-book seizure netting and liquidation flags keyed on cross-asset groups now cover EXCLUDED legs; confirm a seizure of WBTC collateral against USDT flags on the All view and nothing books gross.
- The uncharted-row builder now serving venue rows: no double row for a leg that is also charted;
hiddenBarePtstill hides; dust floors. allTotalno longer withholds on a Not covered entry's null debt; confirm a charted entry's null debt still withholds.- Older client bundles during the deploy minute see an unknown category and drop it silently; acceptable, no crash path.