Skip to content

Valuation consistency, lots improvements, and positions JSON removal#1

Closed
triantos wants to merge 14 commits intodata-model-refactorfrom
phase-b-fixes
Closed

Valuation consistency, lots improvements, and positions JSON removal#1
triantos wants to merge 14 commits intodata-model-refactorfrom
phase-b-fixes

Conversation

@triantos
Copy link
Copy Markdown
Owner

@triantos triantos commented Apr 7, 2026

Summary

Builds on the materialized lots table from PR afadil#792 to fix valuation
consistency issues, improve the lot viewer, eliminate the dead positions
JSON column, and clean up loose ends discovered during testing.

Depends on PR afadil#792 and should be merged after it.

Changes (14 commits)

Valuation consistency

  • Route negative asset categories to liabilities section in net worth
  • Track alternative asset value separately so the investments tab matches
    the sum of account card totals
  • Replace TOTAL pseudo-account valuations with a dedicated
    daily_portfolio_valuation table
  • Show "As of [date]" staleness badge when portfolio data is > 24h old

Lots improvements

  • Rich lot viewer with account grouping and open/closed status
  • Persist fully-consumed lots to the database (was: closed lots silently
    dropped because sync_lots_for_account did a bare UPDATE that affected
    0 rows when the lot had never been INSERTed)
  • Remove the legacy lots: Option<VecDeque<Lot>> field from the Holding
    API struct and use lot_details (LotView) exclusively

Schema and account linking

  • Add nullable account_id FK to assets table
  • Optional account link for alternative assets (create + edit)

Positions JSON removal

  • Read asset metadata from the asset repository instead of snapshot.positions
  • Eliminate all DB reads of the positions JSON column (quote sync, broker
    reconciliation, Tauri get_snapshot_by_date, valuation history)
  • Drop the positions column from holdings_snapshots entirely (migration)
  • Replace scattered "TOTAL" string literals with PORTFOLIO_TOTAL_ACCOUNT_ID

Test infrastructure

  • Add scripts/test-lots-migration.sh for end-to-end API verification

Verification

Tested against real production data on a 24-account portfolio with mixed
TRANSACTIONS and HOLDINGS-mode accounts including bonds, T-bills, options,
and multi-currency positions.

Consumed lots fix — recalculated full portfolio:

  • 778 closed lot rows created (was 0)
  • 228 fully-sold assets with missing lot rows → 0
  • All 630 open lots unchanged

End-to-end API test (scripts/test-lots-migration.sh):

  • 37 endpoints captured before and after recalc on the same code
  • 37/37 exact matches across live holdings, snapshots, valuation history,
    historical holdings at 5 dates spanning the activity range, net worth,
    performance, and alternative holdings
  • Test data covers USD TRANSACTIONS, EUR TRANSACTIONS, HOLDINGS-mode
    manual snapshot, multi-lot FIFO with partial sells, cross-currency
    positions, alternative assets, and TOTAL aggregation

Migrationpositions column drop verified on real database via
PRAGMA table_info.

All 892 unit + integration tests pass. Clippy clean.

triantos added 14 commits April 6, 2026 21:09
build_assets_section silently dropped categories aggregating to ≤ 0.
Replace with build_balance_sheet_sections that routes negative
non-liability categories to the liabilities section with positive
magnitude. Adds 7 unit tests for the new function.
…ents tab

Add alternative_market_value to DailyAccountValuation, computed from
the already-calculated performance_eligible_value in the valuation
calculator. The Investments tab account cards now subtract this value
so they show only tradable securities + cash, matching the header
filter behavior and industry convention (Empower, Kubera, etc.).

Migration adds nullable column with default '0' to daily_account_valuation.
…luation table

Portfolio-level daily valuations now live in a dedicated table instead of
as fake account rows in daily_account_valuation. Portfolio values are
derived by summing per-account valuations (option B), not independently
recalculated from lots.

Migration creates the table, backfills from existing TOTAL rows, and
deletes the old rows. All reads/writes transparently route via the
valuation service. TOTAL holdings snapshots are kept for now (quote
sync planning depends on them).
PortfolioUpdateTrigger now displays a visible "As of [date]" badge
next to the balance when calculatedAt is more than 24 hours old.
Previously the date was only visible on hover. Applies to both the
dashboard and account detail pages.
… status

New LotView display type converts directly from LotRecord, separate
from the calculation Lot struct. Holding gains a lotDetails field
populated with all lots (open + closed) on the asset detail page.

Frontend shows open/closed badges, original vs remaining quantity,
close dates, and groups by account with collapsible sections when
an asset is held across multiple accounts. 4 unit tests for LotView.
Allows linking alternative assets (liabilities, property, etc.) to the
account they belong to. NULL for unlinked assets (house, gold in safe)
and for investment assets (linked via activities/lots instead).
Backend: account_id plumbed through CreateAlternativeAssetRequest,
UpdateAssetDetailsRequest, repository trait, and storage layer.
Frontend: account selector added to both the quick-add modal (create)
and the asset details sheet (edit). "None" option clears the link.
…T_ID constant

Scattered string comparisons against "TOTAL" across 7 files replaced
with the existing constant from crate::constants. Reduces divergence
risk if the ID is ever renamed.
…d of snapshot positions

ValuationService read currency, is_alternative, and contract_multiplier
from snapshot.positions as a fallback when building lot-derived positions.
This was the last snapshot-positions dependency in the valuation pipeline.

Inject asset_repository into ValuationService and pre-fetch asset
metadata for all lot-referenced assets. The valuation calculator now
runs on data sourced entirely from the lots table + assets table,
with no JSON snapshot position reads.
The holdings_snapshots.positions column stored serialized JSON blobs of
security positions with nested lots. This data now lives relationally
in the lots and assets tables.

Stop writing positions to the DB column and stop reading them back.
The in-memory positions field remains on AccountStateSnapshot for the
HoldingsCalculator pipeline (working state during activity replay).

Replace all read-side consumers:
- Valuation history: gather FX pairs and asset metadata from the assets
  table instead of snapshot positions. Move lot+asset fetch earlier in
  calculate_valuation_history so quote and FX prefetch use correct data.
- Quote sync (5 call sites): query lot_repository.get_open_position_quantities()
  for the asset_id->quantity map needed by quote sync planning.
- Tauri get_snapshot_by_date: delegate to holdings_service.holdings_from_snapshot()
  matching the server handler, removing 130 lines of duplicated code.
- Broker reconciliation: build prior positions from lots table, refactor
  compute_holdings_diff to compare position maps directly, replace
  is_content_equal with diff.is_unchanged() + cash equality check.
- Snapshot listing: derive position_count from open lots per account.

Migration clears existing positions JSON: UPDATE holdings_snapshots SET positions = '{}';
Fully-consumed lots (buy + full sell in one recalc pass) were never
written to the DB because LotClosure only carried the lot ID, and
sync_lots_for_account did a bare UPDATE that silently affected 0 rows.

Enriched LotClosure with full lot data (account_id, asset_id, open_date,
original_quantity, cost_per_unit, total_cost_basis, fee_allocated) and
added fully_consumed_lots to FifoReductionResult to capture lot state
before VecDeque removal. Changed the closure path in sync_lots_for_account
to INSERT ON CONFLICT UPDATE so new closed lots are created even if they
were never previously written.

Verified on real data: 778 closed lots created (was 0), zero assets with
missing lot rows (was 228), all 630 open lots unchanged.
Removed the redundant `lots: Option<VecDeque<Lot>>` field from the
Holding API struct and the lot_records_to_display_lots() converter that
populated it. The lot_details field (Option<Vec<LotView>>) — which
reads from the same lots table but includes open/closed status, account
ID, and original quantity — is now the sole lot display path.

apply_fx_to_holding now adjusts lot_details instead of lots. Frontend:
removed LegacyLotsView fallback and lots prop from AssetLotsTable.
The positions column was already dead — reads returned empty HashMap,
writes stored '{}'. This migration drops it entirely and removes all
references from the Diesel model, raw SQL queries, and test fixtures.

The in-memory `positions` field on AccountStateSnapshot remains — it's
the working state during the holdings calculation pipeline, never
persisted.
Bash script that creates 3 test accounts (USD TRANSACTIONS, HOLDINGS-mode,
EUR TRANSACTIONS) with multi-lot FIFO sells, options, alternative assets,
and HOLDINGS-mode manual snapshots. Captures 37 API endpoint responses
before and after a code change and diffs the results, with categorized
expected differences (FX drift, new fields, position count, quantity fix).

Used to verify that lots-related refactors don't introduce regressions
across live holdings, snapshots, valuation history, historical as-of
queries, net worth, performance, and alternative holdings.

Usage:
  ./scripts/test-lots-migration.sh setup     # create test data
  ./scripts/test-lots-migration.sh baseline  # capture (old code)
  ./scripts/test-lots-migration.sh verify    # capture + diff (new code)
  ./scripts/test-lots-migration.sh cleanup   # remove test data
@triantos triantos closed this Apr 7, 2026
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.

1 participant