Skip to content

Add support for options, bonds, and precious metals as instrument types#666

Open
triantos wants to merge 11 commits intoafadil:mainfrom
triantos:v3-instrument-types
Open

Add support for options, bonds, and precious metals as instrument types#666
triantos wants to merge 11 commits intoafadil:mainfrom
triantos:v3-instrument-types

Conversation

@triantos
Copy link
Contributor

@triantos triantos commented Mar 4, 2026

Summary

This PR adds first-class support for options, bonds, and precious metals as distinct instrument types throughout the Wealthfolio stack. Previously, all assets were implicitly treated as equities. Now each asset carries an instrument_type field that drives type-specific validation, market data fetching, and UI presentation.

Key changes across 8 commits:

  • Data model: New InstrumentType enum (Equity, Crypto, Fx, Option, Metal, Bond) with DB migration adding instrument_type columns to assets and activities
  • Parallel enrichment: Asset enrichment during import now runs up to 5 concurrent tasks with SSE progress events
  • Asset enrichment before portfolio: Enrichment job runs before portfolio calculations, not just during import
  • Frontend UI: Instrument type badges, enrichment progress indicators, bond/option-specific form fields
  • Bond providers: New BondQuoteMetadata and provider infrastructure for bond market data
  • CSV import: instrumentType column support, typed symbol prefixes (bond:ISIN, option:OCC), alias normalization (e.g., FIXED_INCOMEBOND)
  • Activity filter: New instrument type dropdown filter on the activities page
  • Tests & docs: 7 backend tests, 32 frontend tests, architecture documentation

Test plan

  • Backend: 1,177 tests pass (cargo test)
  • Backend lint: cargo clippy — 0 errors
  • Frontend: 63 tests pass across 4 test suites (instrument-type, validation-utils, activity-type-mapping, activity-utils)
  • Frontend lint: ESLint — 0 errors
  • parse_instrument_type recognizes all canonical types and aliases including BOND
  • normalizeInstrumentType (frontend) handles canonical types, aliases, case-insensitivity, whitespace/hyphens
  • splitInstrumentPrefixedSymbol parses bond:, option:, crypto:, equity: prefixes
  • CSV import: instrument type from column, from typed prefix, column takes precedence over prefix
  • OCC option symbols (up to 21 chars) pass schema validation
  • ISIN bond symbols (12 chars) pass schema validation
  • Bond import with existing asset enriches name and currency
  • Option import with existing asset enriches name
  • DB migration is idempotent (INSERT OR IGNORE)
  • Manual: Add a bond activity via UI form and verify it saves with instrument_type = BOND
  • Manual: Import CSV with instrumentType column containing bond/option rows
  • Manual: Filter activities page by instrument type

@afadil
Copy link
Owner

afadil commented Mar 5, 2026

planning to merge this soon.
is it possible to double check this finding from a review by Claude:


🔴 Open option positions valued at 1/100th actual value

valuation_calculator.rs:139:
let market_value = position.quantity * normalized_price * quote_fx_rate;

contract_multiplier is correctly applied at trade time (BUY/SELL handlers multiply unit_price *
multiplier for lot cost basis and cash flows), but the valuation path never uses it. Since Yahoo returns
per-share option premiums:

  • Cost basis for 1 contract at $1.50: 1 × $1.50 × 100 = $150 ✓
  • Market value for 1 contract at $2.00 quote: 1 × $2.00 = $2.00 ✗ (should be $200)

Fix:
let market_value = position.quantity * normalized_price * position.contract_multiplier * quote_fx_rate;

contract_multiplier defaults to Decimal::ONE via serde, so existing equity positions are unaffected.


🔴 Option expiry inverts P&L sign

handle_adjustment for OPTION_EXPIRY calls reduce_lots_fifo(qty) and discards the cost basis:
let _cost_basis_removed = position.reduce_lots_fifo(qty);
// No cash effect for expiry

Since there's no realized gain accumulator in the system, the premium loss doesn't just disappear — it
gets inverted. The performance service measures delta(market_value - cost_basis) between snapshots:

  1. Before expiry: cost_basis = $500, market_value ≈ $0 → P&L = -$500
  2. After expiry: lots removed, cost_basis = $0, market_value = $0 → P&L = $0
  3. Performance sees: P&L went from -$500 to $0 → records +$500 gain

A $500 loss shows as a $500 gain in returns.

Fix: Treat expiration as a sell at zero price instead of raw lot removal. A SELL @ unit_price=0 flows
through existing FIFO lot matching and naturally realizes the loss (proceeds $0 - cost $500 = -$500). No
new activity types needed — just change what the OPTION_EXPIRY handler emits internally.


🟡 Exercise form without backend handler

exercise-form.tsx exists but there's no backend subtype constant, compiler expansion, or calculator
handler. Users would need to manually create separate BUY (underlying) + SELL (option) activities with
the right quantities (contracts × multiplier shares at strike price). Consider removing the form from
this PR or adding a clear disclaimer that it's manual-entry only.

@triantos
Copy link
Contributor Author

triantos commented Mar 5, 2026 via email

@triantos
Copy link
Contributor Author

triantos commented Mar 5, 2026 via email

triantos added 9 commits March 5, 2026 15:30
enrich_assets() was processing assets sequentially — each one waiting
for TreasuryDirect and provider profile HTTP calls to complete before
starting the next. For bulk imports this compounds quickly.

Replaced the sequential for loop with buffer_unordered(5) to enrich
up to 5 assets concurrently. Behavior is otherwise identical: same
enrichment calls, same mark_profile_enriched, same logging.
…etals

Phase 2 of the instrument types expansion:

- Add InstrumentType variants (Option, Metal, Bond) with OptionSpec/BondSpec
  metadata structs and helper methods on Asset
- Add InstrumentId/InstrumentKind variants for market data routing
- Add identifier utilities: CUSIP parser, ISIN validator, OCC symbol
  parser/builder with full test coverage
- Extend holdings calculator with contract_multiplier support for
  options (qty * price * multiplier) and Position struct field
- Add ACTIVITY_SUBTYPE_OPTION_EXPIRY and handle_adjustment for
  option expiry (FIFO lot removal, no cash effect)
- Add resolver rules for bonds (ISIN routing), options (OCC via Yahoo),
  and metals (futures symbols, Metal Price API)
- Register Phase 2 providers: US_TREASURY_CALC, BOERSE_FRANKFURT,
  OPENFIGI, METAL_PRICE_API
- Add BondIsin variant to ProviderInstrument with exhaustive match
  coverage across all providers
Ensure bond/option metadata is available for pricing before the portfolio
snapshot job runs: queue_worker now awaits enrichment completion instead of
fire-and-forget spawning.

Adds four SSE event constants (ASSET_ENRICHMENT_START, ASSET_ENRICHMENT_PROGRESS,
ASSET_ENRICHMENT_COMPLETE, ASSET_ENRICHMENT_ERROR) and emits chunked progress
events during bulk enrichment so the frontend can show a progress indicator.
Activity forms: asset-type selector (Stock/Option/Bond/Metal) on the buy
form, option contract fields with OCC symbol parsing, exercise and expiry
forms for option lifecycle events. Buy form supports discriminated asset
types with option total premium calculation.

Frontend event system: enrichment event listeners in Tauri and web adapters,
portfolio-sync-context tracks enriching-assets status, global event listener
shows enrichment progress toasts. Adds occ-symbol.ts parser/builder utility.
Three new providers for bond pricing and enrichment:
- US Treasury calc: yield-curve-based pricing from Treasury.gov XML
- Boerse Frankfurt: Deutsche Boerse bond prices with salt-based auth
- OpenFIGI: ISIN-to-name resolution for bond profile enrichment

Also adds BondQuoteMetadata to QuoteContext for passing coupon/maturity
data to yield-curve providers, and registers all providers in the
market-data crate.
- ISIN validator with Luhn check digit (isin.ts)
- Instrument type normalizer and typed symbol prefix parser (bond:, option:, crypto:)
- INSTRUMENT_TYPE added to ImportFormat with column alias auto-mapping
- Broader symbol validation: OCC options, ISINs, CUSIPs now accepted
- instrumentType column in both import grids (select with "Auto" empty label)
- Review step extracts/normalizes instrument type from CSV column, prefix, or mapping meta
- Sample CSV files updated with instrumentType column
- SQLite: add assets.instrument_type to ActivityDetailsDB SELECT and From impl
- Core: add instrument_type field to ActivityDetails model
- Traits/service/mocks: add instrument_type_filter param to search_activities
- Repository: filter by instrument_type when provided
- Tauri command + server API: accept and pass through instrument_type_filter
- Frontend adapter: add instrumentTypes to ActivityFilters, normalize and invoke
- Hook: add instrumentTypes to ActivitySearchFilters, include in normalized filters
- Activity page: selectedInstrumentTypes persistent state wired into both search modes
- View controls: Instrument FacetedFilter (after Type), reset handler, hasActiveFilters
- DataGrid column: instrumentType Badge column (hidden by default, toggleable)
- DataGrid toolbar: instrumentType in COLUMN_DISPLAY_NAMES + TOGGLEABLE_COLUMNS
Backend: 7 new tests covering bond and option import paths
- Bond: check/apply recognition, alias normalization, asset enrichment
- Option: check/apply recognition, existing asset enrichment

Frontend: 12 new tests for instrument-type.ts
- normalizeInstrumentType: canonical types, aliases, case, whitespace
- splitInstrumentPrefixedSymbol: prefix parsing, edge cases

Docs: docs/instruments/
- overview.md: canonical types, alias tables, CSV import, precedence
- activity-search.md: search API, 0-based pagination, NULL asset behavior
- Apply contract_multiplier in valuation calculator so open option
  positions are valued correctly (qty * price * multiplier * fx_rate)
- Remove exercise-form.tsx which was never imported or wired up
@triantos triantos force-pushed the v3-instrument-types branch from 0226550 to fdec33c Compare March 5, 2026 23:31
Fix formatting to pass CI checks (rustfmt alignment, prettier
whitespace in frontend files and docs).
@triantos triantos force-pushed the v3-instrument-types branch from 46a3f48 to 4a0f39f Compare March 5, 2026 23:51
- Restore assetId validation for stocks/bonds via superRefine (options
  build their OCC symbol at submit time, so empty assetId is allowed)
- Add AssetTypeSelector and OptionContractFields to buy-form test mock
  to match the new exports in fields/index.ts
- Fix clippy redundant_pattern_matching lint in assets_service.rs
  (new in Rust 1.94; masked locally by running Rust 1.93)
@mmaha
Copy link

mmaha commented Mar 6, 2026

@triantos Thank you for the PR - Very nice. I'll have to test this later this week.

Quick question - will this allow for short options (-ve quantities) as well? From what I can gather this is not the case currently, correct?

@triantos
Copy link
Contributor Author

triantos commented Mar 6, 2026

@triantos Thank you for the PR - Very nice. I'll have to test this later this week.

Quick question - will this allow for short options (-ve quantities) as well? From what I can gather this is not the case currently, correct?

I hadn't done any short options so I'll admit I forgot to test this case. Should be easy to add once these changes are in the core.

@mmaha
Copy link

mmaha commented Mar 6, 2026

Looks like there is a PR for this: #399

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants