Add support for options, bonds, and precious metals as instrument types#666
Add support for options, bonds, and precious metals as instrument types#666triantos wants to merge 11 commits intoafadil:mainfrom
Conversation
|
planning to merge this soon. 🔴 Open option positions valued at 1/100th actual value valuation_calculator.rs:139: contract_multiplier is correctly applied at trade time (BUY/SELL handlers multiply unit_price *
Fix: 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: Since there's no realized gain accumulator in the system, the premium loss doesn't just disappear — it
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 🟡 Exercise form without backend handler exercise-form.tsx exists but there's no backend subtype constant, compiler expansion, or calculator |
|
Apologies, I lost one change when I refactored, that handled the first two. I'll have to look at the second one shortly. I'll make sure to send an updated PR by late tonight if I can.
… On Mar 5, 2026, at 9:33 AM, FADIL ***@***.***> wrote:
afadil
left a comment
(afadil/wealthfolio#666)
<#666 (comment)>
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:
Before expiry: cost_basis = $500, market_value ≈ $0 → P&L = -$500
After expiry: lots removed, cost_basis = $0, market_value = $0 → P&L = $0
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.
—
Reply to this email directly, view it on GitHub <#666 (comment)>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AAC3P4MSPF4BR5NWBF53FJ34PG26NAVCNFSM6AAAAACWHIVIP6VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHM2DAMBWGU3TMMRXGI>.
You are receiving this because you authored the thread.
|
|
The first item is a legit bug and a one-line fix, which I will push.
As far as I can tell, the second option is actually not a bug. OPTION_EXPIRY is only a thing for accounts in transactions mode, not holdings mode. The bug would only exist for holdings mode accounts. I've confirmed this manually, and also had Claude run a test to verify it as well.
The third one was dead code so I've stripped it out. I had a lot more I've been doing in my branch, but I figured I'd rather get this in and then add to it, than to overwhelm you again. :-)
thank you!
… On Mar 5, 2026, at 10:01 AM, Nick Triantos ***@***.***> wrote:
Apologies, I lost one change when I refactored, that handled the first two. I'll have to look at the second one shortly. I'll make sure to send an updated PR by late tonight if I can.
> On Mar 5, 2026, at 9:33 AM, FADIL ***@***.***> wrote:
>
>
> afadil
> left a comment
> (afadil/wealthfolio#666)
> <#666 (comment)>
> 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:
>
> Before expiry: cost_basis = $500, market_value ≈ $0 → P&L = -$500
> After expiry: lots removed, cost_basis = $0, market_value = $0 → P&L = $0
> 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.
>
> —
> Reply to this email directly, view it on GitHub <#666 (comment)>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AAC3P4MSPF4BR5NWBF53FJ34PG26NAVCNFSM6AAAAACWHIVIP6VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHM2DAMBWGU3TMMRXGI>.
> You are receiving this because you authored the thread.
>
|
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
0226550 to
fdec33c
Compare
Fix formatting to pass CI checks (rustfmt alignment, prettier whitespace in frontend files and docs).
46a3f48 to
4a0f39f
Compare
- 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)
|
@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. |
|
Looks like there is a PR for this: #399 |
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_typefield that drives type-specific validation, market data fetching, and UI presentation.Key changes across 8 commits:
InstrumentTypeenum (Equity, Crypto, Fx, Option, Metal, Bond) with DB migration addinginstrument_typecolumns toassetsandactivitiesBondQuoteMetadataand provider infrastructure for bond market datainstrumentTypecolumn support, typed symbol prefixes (bond:ISIN,option:OCC), alias normalization (e.g.,FIXED_INCOME→BOND)Test plan
cargo test)cargo clippy— 0 errorsparse_instrument_typerecognizes all canonical types and aliases including BONDnormalizeInstrumentType(frontend) handles canonical types, aliases, case-insensitivity, whitespace/hyphenssplitInstrumentPrefixedSymbolparsesbond:,option:,crypto:,equity:prefixesINSERT OR IGNORE)instrument_type = BONDinstrumentTypecolumn containing bond/option rows